我有一个梦想:用算法还原世界;在世界应用算法。
目录
- [Perlin Noise](#Perlin Noise)
- [Simplex Noise](#Simplex Noise)
- 框架定义
- [梯度采样 & 图采样点噪声值计算(Simplex核心)](#梯度采样 & 图采样点噪声值计算(Simplex核心))
- [Value Noise](#Value Noise)
- 框架定义
- [格点采样 & 图采样点噪声值计算(Value核心)](#格点采样 & 图采样点噪声值计算(Value核心))
欸?说起世界,每当我又一次耕完一片土地等待收获的时候,都会回想起忘乎所以地探索《我的世界》的那个下午...直到如今,我也非常好奇究竟什么样的算法才能将一个如此真实合理的世界呈现在大家眼前。废话不多说,接下来我将用Go语言带领大家走进奇妙的地图生成算法世界~
Perlin Noise
关于这个算法的起源和理论就不多赘述,各处各地都不乏各种各样的资料介绍它的前世今生,可往往少有关于它具体实现的介绍。因此本文将放眼于每一行代码的实现,从零开始实现这些算法。
准备工作
为大部分的噪声生成器定义一个统一的框架,输出相同格式的噪声便于后续的可视化和解析,用相同的架构逻辑辅助思考和代码实现。需要一个注册方法注册已有的算法,在每一个算法中通过"创建"接口返回一个Generator()方法,它接收对应的算法配置Config返回真正的算法对象Algorithm。
go
package algorithm
import (
"encoding/json"
"fmt"
"reflect"
"strings"
)
// BaseConfig 包含所有算法共享的基础生成参数。
type BaseConfig struct {
Dimension int `json:"dimension,omitempty"`
Shape []int `json:"shape,omitempty"`
Step []float64 `json:"step,omitempty"`
Offset []float64 `json:"offset,omitempty"`
Seed int64 `json:"seed,omitempty"`
}
// NDArray 用于存放生成的 N 维数据,一维化平铺存储。
type NDArray struct {
Shape []int
Data []float64
}
// AlgorithmGenerator 是所有具体算法生成器都需要实现的公共接口。
type AlgorithmGenerator interface {
Generate(args ...any) (*NDArray, error)
Reset(args ...any) error
}
type generatorBuilder func(any) (AlgorithmGenerator, error)
var generatorRegistry = map[reflect.Type]generatorBuilder{}
// RegisterGenerator 注册一种算法配置与其生成器构造函数的映射关系。
// 具体算法包通常会在 init() 中完成注册。
func RegisterGenerator(configPrototype any, builder func(any) (AlgorithmGenerator, error)) {
if configPrototype == nil {
panic("algorithm: config prototype cannot be nil")
}
if builder == nil {
panic("algorithm: generator builder cannot be nil")
}
generatorRegistry[reflect.TypeOf(configPrototype)] = builder
}
// NewGenerator 根据配置结构体类型分发到对应算法的生成器实现。
// 使用前需要确保对应算法包已被导入,从而完成初始化注册。
func NewGenerator(algoConfig any) (AlgorithmGenerator, error) {
if algoConfig == nil {
return nil, fmt.Errorf("algorithm config cannot be nil")
}
builder, ok := generatorRegistry[reflect.TypeOf(algoConfig)]
if !ok {
return nil, fmt.Errorf("unsupported algorithm config type: %T", algoConfig)
}
return builder(algoConfig)
}
// NewOwnedBaseConfig 在初始化阶段创建一份生成器私有配置。
func NewOwnedBaseConfig(cfg BaseConfig) (BaseConfig, error) {
owned, err := cloneConfig(cfg)
if err != nil {
return BaseConfig{}, err
}
if err := NormalizeBaseConfig(&owned, false); err != nil {
return BaseConfig{}, err
}
return owned, nil
}
// NewOwnedConfig 创建生成器初始化配置。
// 传入 *T 时直接复用该指针;传入 T 时复制一份作为生成器私有配置。
func NewOwnedConfig[T any](config any, normalize func(*T, bool) error) (*T, error) {
if typed, ok := config.(*T); ok {
if typed == nil {
return nil, fmt.Errorf("config pointer cannot be nil")
}
if normalize != nil {
if err := normalize(typed, false); err != nil {
return nil, err
}
}
return typed, nil
}
typed, ok := config.(T)
if !ok {
return nil, fmt.Errorf("config must be the target config type or pointer, got %T", config)
}
owned, err := cloneConfig(typed)
if err != nil {
return nil, err
}
if normalize != nil {
if err := normalize(&owned, false); err != nil {
return nil, err
}
}
return &owned, nil
}
// ResolveConfig 基于已有配置与传入 patch 生成本次调用所需配置。
// 支持三种输入:
// 1. map[string]any
// 2. 成对的 "key", value
// 3. 配置结构体或其指针
func ResolveConfig[T any](
_ string,
base T,
requireShape bool,
normalize func(*T, bool) error,
args ...any,
) (T, error) {
cfg, err := cloneConfig(base)
if err != nil {
var zero T
return zero, err
}
for i := 0; i < len(args); i++ {
switch v := args[i].(type) {
case string:
if i+1 >= len(args) {
continue
}
_, err := ApplyJSONPatch(&cfg, map[string]any{v: args[i+1]})
if err != nil {
var zero T
return zero, err
}
i++
default:
_, handled, err := patchFields(v)
if err != nil {
var zero T
return zero, err
}
if !handled {
continue
}
if _, err := ApplyJSONPatch(&cfg, v); err != nil {
var zero T
return zero, err
}
}
}
if normalize != nil {
if err := normalize(&cfg, requireShape); err != nil {
var zero T
return zero, err
}
}
return cfg, nil
}
// ApplyJSONPatch 按 json tag 或字段名把 patch 写入配置。
func ApplyJSONPatch(dst any, patch any) ([]string, error) {
fields, handled, err := patchFields(patch)
if err != nil {
return nil, err
}
if !handled {
return nil, fmt.Errorf("patch must be map[string]any or a struct, got %T", patch)
}
if len(fields) == 0 {
return nil, nil
}
structValue, err := structPointerValue(dst)
if err != nil {
return nil, err
}
var ignored []string
for key, raw := range fields {
fieldValue, matched := findFieldByKey(structValue, NormalizeArgumentKey(key))
if !matched {
ignored = append(ignored, key)
continue
}
if !fieldValue.CanAddr() || !fieldValue.CanSet() {
return nil, fmt.Errorf("%s cannot be set", key)
}
if err := json.Unmarshal(raw, fieldValue.Addr().Interface()); err != nil {
return nil, fmt.Errorf("%s: %w", key, err)
}
}
return ignored, nil
}
// NormalizeArgumentKey 统一参数 key,支持大小写、下划线和中划线的宽松写法。
func NormalizeArgumentKey(key string) string {
normalized := strings.TrimSpace(strings.ToLower(key))
normalized = strings.ReplaceAll(normalized, "_", "")
normalized = strings.ReplaceAll(normalized, "-", "")
return normalized
}
// NormalizeBaseConfig 对基础配置做统一归一化和校验。
// requireShape=false 用于生成器持久配置,允许先不提供 Shape。
// requireShape=true 用于 Generate 调用,要求 Shape 已完整可用。
func NormalizeBaseConfig(cfg *BaseConfig, requireShape bool) error {
if cfg == nil || len(cfg.Shape) == 0 {
if requireShape {
return fmt.Errorf("shape cannot be empty")
}
return nil
}
dims := len(cfg.Shape)
for _, dimSize := range cfg.Shape {
if dimSize <= 0 {
return fmt.Errorf("invalid shape dimension size: %d", dimSize)
}
}
if cfg.Dimension <= 0 || cfg.Dimension != dims {
cfg.Dimension = dims
}
if len(cfg.Step) != 0 && len(cfg.Step) != dims {
return fmt.Errorf("length of Step must match length of Shape")
}
if len(cfg.Offset) != 0 && len(cfg.Offset) != dims {
return fmt.Errorf("length of Offset must match length of Shape")
}
return nil
}
func cloneConfig[T any](src T) (T, error) {
var dst T
data, err := json.Marshal(src)
if err != nil {
return dst, err
}
if err := json.Unmarshal(data, &dst); err != nil {
return dst, err
}
return dst, nil
}
func patchFields(patch any) (map[string]json.RawMessage, bool, error) {
if patch == nil {
return nil, true, nil
}
value := reflect.ValueOf(patch)
switch value.Kind() {
case reflect.Map:
if value.Type().Key().Kind() != reflect.String {
return nil, false, nil
}
case reflect.Struct:
case reflect.Pointer:
if value.IsNil() {
return nil, true, nil
}
if value.Elem().Kind() != reflect.Struct {
return nil, false, nil
}
default:
return nil, false, nil
}
data, err := json.Marshal(patch)
if err != nil {
return nil, true, err
}
fields := map[string]json.RawMessage{}
if err := json.Unmarshal(data, &fields); err != nil {
return nil, true, err
}
return fields, true, nil
}
func structPointerValue(dst any) (reflect.Value, error) {
if dst == nil {
return reflect.Value{}, fmt.Errorf("destination cannot be nil")
}
value := reflect.ValueOf(dst)
if value.Kind() != reflect.Pointer || value.IsNil() {
return reflect.Value{}, fmt.Errorf("destination must be a non-nil pointer to struct")
}
value = value.Elem()
if value.Kind() != reflect.Struct {
return reflect.Value{}, fmt.Errorf("destination must point to a struct")
}
return value, nil
}
func findFieldByKey(structValue reflect.Value, key string) (reflect.Value, bool) {
structType := structValue.Type()
for i := 0; i < structType.NumField(); i++ {
fieldType := structType.Field(i)
fieldValue := structValue.Field(i)
if fieldType.PkgPath != "" {
continue
}
if fieldType.Anonymous {
embeddedValue := fieldValue
if embeddedValue.Kind() == reflect.Pointer {
if embeddedValue.IsNil() {
continue
}
embeddedValue = embeddedValue.Elem()
}
if embeddedValue.Kind() == reflect.Struct {
if nestedValue, ok := findFieldByKey(embeddedValue, key); ok {
return nestedValue, true
}
}
}
if fieldMatchesKey(fieldType, key) {
return fieldValue, true
}
}
return reflect.Value{}, false
}
func fieldMatchesKey(field reflect.StructField, key string) bool {
if tagName, ok := fieldTagName(field, "json"); ok && NormalizeArgumentKey(tagName) == key {
return true
}
return NormalizeArgumentKey(field.Name) == key
}
func fieldTagName(field reflect.StructField, tagKey string) (string, bool) {
tagValue, ok := field.Tag.Lookup(tagKey)
if !ok {
return "", false
}
name := strings.TrimSpace(strings.Split(tagValue, ",")[0])
if name == "" || name == "-" {
return "", false
}
return name, true
}
算法实现
go
package perlin
import (
"go_service3/algorithm"
"math"
"math/rand"
"time"
)
func lattice_hash(seed *int64) func(coords ...int) int {
var perm_512 [512]int
for i := range perm_512 {
perm_512[i] = i
}
var r *rand.Rand
if seed != nil && *seed > 0 {
r = rand.New(rand.NewSource(*seed))
} else {
r = rand.New(rand.NewSource(time.Now().UnixNano()))
}
r.Shuffle(512, func(i, j int) {
perm_512[i], perm_512[j] = perm_512[j], perm_512[i]
})
return func(coords ...int) int {
h := 0
for _, c := range coords {
h += perm_512[(h+c)%512]
}
return h
}
}
func gradient_at_dim2(seed *int64) func(x, y int) [2]float64 {
var direction [8][2]float64
for i := range direction {
angle := float64(i) * 2 * math.Pi / 8
direction[i][0] = math.Cos(angle)
direction[i][1] = math.Sin(angle)
}
// var r *rand.Rand
// if seed != nil && *seed > 0 {
// r = rand.New(rand.NewSource(*seed))
// } else {
// r = rand.New(rand.NewSource(time.Now().UnixNano()))
// }
// r.Shuffle(8, func(i, j int) {
// direction[i], direction[j] = direction[j], direction[i]
// })
hash_func := lattice_hash(seed)
return func(x, y int) [2]float64 {
return direction[hash_func(x, y)%8]
}
}
func noise_2d_learn(u, v float64, grad_2d func(x, y int) [2]float64) float64 {
var noise float64 = 0.0
x0 := math.Floor(u)
y0 := math.Floor(v)
x1 := x0 + 1
y1 := y0 + 1
tx := u - float64(x0)
ty := v - float64(y0)
g00 := grad_2d(int(x0), int(y0))
g01 := grad_2d(int(x0), int(y1))
g10 := grad_2d(int(x1), int(y0))
g11 := grad_2d(int(x1), int(y1))
n00 := g00[0]*tx + g00[1]*ty
n01 := g01[0]*tx + g01[1]*(ty-1)
n10 := g10[0]*(tx-1) + g10[1]*ty
n11 := g11[0]*(tx-1) + g11[1]*(ty-1)
wx := fade(tx)
wy := fade(ty)
ix0 := lerp(n00, n10, wx)
ix1 := lerp(n01, n11, wx)
noise = lerp(ix0, ix1, wy)
return noise
}
func noise_2d(u, v float64, octaves int, persistence float64, lacunarity float64, grad_2d func(x, y int) [2]float64) float64 {
var noise_sum float64 = 0.0
var amp float64 = 1.0
var amp_sum float64 = 0.0
var freq float64 = 1.0
for o := 0; o < octaves; o++ {
noise_sum += noise_2d_learn(u*freq, v*freq, grad_2d) * amp
amp_sum += amp
amp *= persistence
freq *= lacunarity
}
return noise_sum / amp_sum
}
func lerp(a, b, t float64) float64 {
return a + (b-a)*t
}
func fade(t float64) float64 {
return t * t * t * (t*(t*6.0-15.0) + 10.0)
}
func perlin_noise(cfg *PerlinConfig) *algorithm.NDArray {
grad_2d := gradient_at_dim2(&cfg.Seed)
width := cfg.Shape[0]
height := cfg.Shape[1]
noises := make([]float64, width*height)
var offsetx, offsety float64
if len(cfg.Offset) >= 2 {
offsetx = math.Abs(cfg.Offset[0])
offsety = math.Abs(cfg.Offset[1])
}
var stepx, stepy float64 = 1, 1
if len(cfg.Step) >= 2 {
stepx = math.Abs(cfg.Step[0])
stepy = math.Abs(cfg.Step[1])
}
octaves := cfg.Octaves
if octaves < 1 {
octaves = 1
}
persistence := cfg.Persistence
if persistence <= 0 {
persistence = 0.5
}
lacunarity := cfg.Lacunarity
if lacunarity <= 0 {
lacunarity = 2.0
}
idx := 0
for i := 0; i < width; i++ {
for j := 0; j < height; j++ {
u := float64(i)*stepx + offsetx
v := float64(j)*stepy + offsety
noises[idx] = noise_2d(u, v, octaves, persistence, lacunarity, grad_2d)
idx++
}
}
return &algorithm.NDArray{Shape: cfg.Shape, Data: noises}
}
格点采样方法
在这里定义一个lattice_hash函数,它可以被seed控制,返回一个func类型,通过传入的int类型的格点坐标返回一个"伪随机"的整数。我在一开始的时候会想,难道不应该"拥抱"随机来让地形更加的丰富多样吗?然而事实却是如果采用"全随机",在某些地方会出现极端的变化或者不期望的"伪影",影响整体效果。因此在这里的绝大部分方法里都采用了"伪随机"(确定几个方向、类别、值等)作为生成的底料。
go
package perlin
import (
"go_service3/algorithm"
"math"
"math/rand"
"time"
)
func lattice_hash(seed *int64) func(coords ...int) int {
var perm_512 [512]int
for i := range perm_512 {
perm_512[i] = i
}
var r *rand.Rand
if seed != nil && *seed > 0 {
r = rand.New(rand.NewSource(*seed))
} else {
r = rand.New(rand.NewSource(time.Now().UnixNano()))
}
r.Shuffle(512, func(i, j int) {
perm_512[i], perm_512[j] = perm_512[j], perm_512[i]
})
return func(coords ...int) int {
h := 0
for _, c := range coords {
h += perm_512[(h+c)%512]
}
return h
}
}
梯度采样
这里设计一个梯度采样函数,在每一个格点分配对应的方向。原文中仅仅8个方向就可以模拟出表现足够丰富的噪声图。另外在格点处的随机值原文也是使用256即可。后来了解发现,其实只要满足方向足够均匀(标准化为1并且方向向量和趋近于0),或者根据实际情况偏向某个希望的位置都可以。因此这里用16个、32个、无数个方向都可行!
go
func gradient_at_dim2(seed *int64) func(x, y int) [2]float64 {
var direction [8][2]float64
for i := range direction {
angle := float64(i) * 2 * math.Pi / 8
direction[i][0] = math.Cos(angle)
direction[i][1] = math.Sin(angle)
}
// var r *rand.Rand
// if seed != nil && *seed > 0 {
// r = rand.New(rand.NewSource(*seed))
// } else {
// r = rand.New(rand.NewSource(time.Now().UnixNano()))
// }
// r.Shuffle(8, func(i, j int) {
// direction[i], direction[j] = direction[j], direction[i]
// })
hash_func := lattice_hash(seed)
return func(x, y int) [2]float64 {
return direction[hash_func(x, y)%8]
}
}
图采样点噪声值计算(perlin核心)
确定了格点并且为每个格点分配好方向后,就可以根据实际的采样策略来计算每一个点的噪声值了。这一块在value中会更加简单,插值、平滑等操作只是为了让图片看起来更"流畅",避免简单的线性插值造成的边缘伪影和变化突兀单调等不良情况。
go
func noise_2d_learn(u, v float64, grad_2d func(x, y int) [2]float64) float64 {
var noise float64 = 0.0
x0 := math.Floor(u)
y0 := math.Floor(v)
x1 := x0 + 1
y1 := y0 + 1
tx := u - float64(x0)
ty := v - float64(y0)
g00 := grad_2d(int(x0), int(y0))
g01 := grad_2d(int(x0), int(y1))
g10 := grad_2d(int(x1), int(y0))
g11 := grad_2d(int(x1), int(y1))
n00 := g00[0]*tx + g00[1]*ty
n01 := g01[0]*tx + g01[1]*(ty-1)
n10 := g10[0]*(tx-1) + g10[1]*ty
n11 := g11[0]*(tx-1) + g11[1]*(ty-1)
wx := fade(tx)
wy := fade(ty)
ix0 := lerp(n00, n10, wx)
ix1 := lerp(n01, n11, wx)
noise = lerp(ix0, ix1, wy)
return noise
}
func lerp(a, b, t float64) float64 {
return a + (b-a)*t
}
func fade(t float64) float64 {
return t * t * t * (t*(t*6.0-15.0) + 10.0)
}
结合fBm方法
关于fBm的详细逻辑在之后会进行介绍,在这里直接使用这个算法,来增加图像的层次性。相关的丰富图像方法还有Domain Noise等。
go
func noise_2d(u, v float64, octaves int, persistence float64, lacunarity float64, grad_2d func(x, y int) [2]float64) float64 {
var noise_sum float64 = 0.0
var amp float64 = 1.0
var amp_sum float64 = 0.0
var freq float64 = 1.0
for o := 0; o < octaves; o++ {
noise_sum += noise_2d_learn(u*freq, v*freq, grad_2d) * amp
amp_sum += amp
amp *= persistence
freq *= lacunarity
}
return noise_sum / amp_sum
}
导出
将计算好的噪声按照统一的格式导出,方便后续的处理和使用。
go
func perlin_noise(cfg *PerlinConfig) *algorithm.NDArray {
grad_2d := gradient_at_dim2(&cfg.Seed)
width := cfg.Shape[0]
height := cfg.Shape[1]
noises := make([]float64, width*height)
var offsetx, offsety float64
if len(cfg.Offset) >= 2 {
offsetx = math.Abs(cfg.Offset[0])
offsety = math.Abs(cfg.Offset[1])
}
var stepx, stepy float64 = 1, 1
if len(cfg.Step) >= 2 {
stepx = math.Abs(cfg.Step[0])
stepy = math.Abs(cfg.Step[1])
}
octaves := cfg.Octaves
if octaves < 1 {
octaves = 1
}
persistence := cfg.Persistence
if persistence <= 0 {
persistence = 0.5
}
lacunarity := cfg.Lacunarity
if lacunarity <= 0 {
lacunarity = 2.0
}
idx := 0
for i := 0; i < width; i++ {
for j := 0; j < height; j++ {
u := float64(i)*stepx + offsetx
v := float64(j)*stepy + offsety
noises[idx] = noise_2d(u, v, octaves, persistence, lacunarity, grad_2d)
idx++
}
}
return &algorithm.NDArray{Shape: cfg.Shape, Data: noises}
}
使用算法
main函数
结合后续可视化代码的实现,将写好的噪声算法进行简单的可视化查看效果。
go
package main
import (
"fmt"
"path/filepath"
"go_service3/algorithm"
"go_service3/algorithm/perlin"
)
func main() {
// 配置参考:
// 1. 类世界地图 / 大陆块:
// Shape: []int{512, 512}
// Step: []float64{0.004, 0.004} ~ []float64{0.008, 0.008}
// Seed: 13
// Octaves: 4 ~ 5
// Persistence: 0.35 ~ 0.45
// Lacunarity: 1.9 ~ 2.1
// 说明:低频为主,大块结构更明显。想更像地图,后面通常还要再加"海平面阈值",
// 例如把较暗区域当海洋、较亮区域当陆地;单纯灰度图本身仍会偏连续噪声。
//
// 2. 云雾 / 烟雾:
// Step: []float64{0.015, 0.015} ~ []float64{0.04, 0.04}
// Octaves: 5 ~ 7
// Persistence: 0.5 ~ 0.65
// Lacunarity: 2.0
//
// 3. 山地 / 粗糙地形:
// Step: []float64{0.008, 0.008} ~ []float64{0.02, 0.02}
// Octaves: 6 ~ 8
// Persistence: 0.55 ~ 0.7
// Lacunarity: 2.2 ~ 2.6
//
// 4. 平缓丘陵 / 柔和起伏:
// Step: []float64{0.006, 0.006} ~ []float64{0.012, 0.012}
// Octaves: 3 ~ 4
// Persistence: 0.35 ~ 0.5
// Lacunarity: 1.8 ~ 2.0
//
// 5. 拉伸纹理 / 风带感:
// Step: []float64{0.003, 0.012} 或 []float64{0.012, 0.003}
// Octaves: 4 ~ 6
// Persistence: 0.45 ~ 0.6
// Lacunarity: 2.0 ~ 2.3
// 说明:x/y 步长不同,会把纹理拉成长条。
cfg := perlin.PerlinConfig{
BaseConfig: algorithm.BaseConfig{
Shape: []int{2048, 2048},
Step: []float64{0.003, 0.0012},
Offset: []float64{0, 0},
},
Octaves: 5,
Persistence: 0.5,
Lacunarity: 2.1,
}
outputPath := filepath.Join("cmd", "test", "perlin_noise2.png")
if err := perlin.SaveNoiseImage2D(&cfg, outputPath); err != nil {
panic(err)
}
fmt.Printf("Perlin noise image saved to: %s\n", outputPath)
}
可视化
简单的噪声可视化。你也可以将这个代码丢给AI,让它按照数值的大小涂上不同的颜色,变成"分层设色地形图"(doge),可以更直观地看到图像的丰富变化,以及明显的重复性。关于重复性的问题,后续将应用更多的算法来解决~
go
package algorithm
import (
"fmt"
"image"
"image/color"
"image/png"
"os"
)
func NoiseImage1D(arr *NDArray) (*image.Gray, error) {
if arr == nil {
return nil, fmt.Errorf("ndarray cannot be nil")
}
if len(arr.Shape) != 1 {
return nil, fmt.Errorf("NoiseImage1D requires 1D shape, got %d dimensions", len(arr.Shape))
}
if arr.Shape[0] <= 0 {
return nil, fmt.Errorf("shape values must be positive")
}
if len(arr.Data) != arr.Shape[0] {
return nil, fmt.Errorf("noise data length mismatch: got %d, want %d", len(arr.Data), arr.Shape[0])
}
return noiseImageFrom2DFlat(arr.Shape[0], 1, arr.Data), nil
}
func SaveNoiseImage1D(arr *NDArray, outputPath string) error {
img, err := NoiseImage1D(arr)
if err != nil {
return err
}
return saveGrayPNG(img, outputPath)
}
func NoiseImage2D(arr *NDArray) (*image.Gray, error) {
if arr == nil {
return nil, fmt.Errorf("ndarray cannot be nil")
}
if len(arr.Shape) != 2 {
return nil, fmt.Errorf("NoiseImage2D requires 2D shape, got %d dimensions", len(arr.Shape))
}
width := arr.Shape[0]
height := arr.Shape[1]
if width <= 0 || height <= 0 {
return nil, fmt.Errorf("shape values must be positive")
}
if len(arr.Data) != width*height {
return nil, fmt.Errorf("noise data length mismatch: got %d, want %d", len(arr.Data), width*height)
}
return noiseImageFrom2DFlat(width, height, arr.Data), nil
}
func SaveNoiseImage2D(arr *NDArray, outputPath string) error {
img, err := NoiseImage2D(arr)
if err != nil {
return err
}
return saveGrayPNG(img, outputPath)
}
func NoiseImage3D(arr *NDArray, z int) (*image.Gray, error) {
if arr == nil {
return nil, fmt.Errorf("ndarray cannot be nil")
}
if len(arr.Shape) != 3 {
return nil, fmt.Errorf("NoiseImage3D requires 3D shape, got %d dimensions", len(arr.Shape))
}
width := arr.Shape[0]
height := arr.Shape[1]
depth := arr.Shape[2]
if width <= 0 || height <= 0 || depth <= 0 {
return nil, fmt.Errorf("shape values must be positive")
}
if z < 0 || z >= depth {
return nil, fmt.Errorf("z index out of range: got %d, want [0,%d)", z, depth)
}
if len(arr.Data) != width*height*depth {
return nil, fmt.Errorf("noise data length mismatch: got %d, want %d", len(arr.Data), width*height*depth)
}
flat := make([]float64, width*height)
idx := 0
for x := 0; x < width; x++ {
for y := 0; y < height; y++ {
flat[idx] = arr.Data[indexOf(arr.Shape, x, y, z)]
idx++
}
}
return noiseImageFrom2DFlat(width, height, flat), nil
}
func SaveNoiseImage3D(arr *NDArray, z int, outputPath string) error {
img, err := NoiseImage3D(arr, z)
if err != nil {
return err
}
return saveGrayPNG(img, outputPath)
}
func NoiseImage4D(arr *NDArray, z int, w int) (*image.Gray, error) {
if arr == nil {
return nil, fmt.Errorf("ndarray cannot be nil")
}
if len(arr.Shape) != 4 {
return nil, fmt.Errorf("NoiseImage4D requires 4D shape, got %d dimensions", len(arr.Shape))
}
width := arr.Shape[0]
height := arr.Shape[1]
depth := arr.Shape[2]
timeLen := arr.Shape[3]
if width <= 0 || height <= 0 || depth <= 0 || timeLen <= 0 {
return nil, fmt.Errorf("shape values must be positive")
}
if z < 0 || z >= depth {
return nil, fmt.Errorf("z index out of range: got %d, want [0,%d)", z, depth)
}
if w < 0 || w >= timeLen {
return nil, fmt.Errorf("w index out of range: got %d, want [0,%d)", w, timeLen)
}
if len(arr.Data) != width*height*depth*timeLen {
return nil, fmt.Errorf("noise data length mismatch: got %d, want %d", len(arr.Data), width*height*depth*timeLen)
}
flat := make([]float64, width*height)
idx := 0
for x := 0; x < width; x++ {
for y := 0; y < height; y++ {
flat[idx] = arr.Data[indexOf(arr.Shape, x, y, z, w)]
idx++
}
}
return noiseImageFrom2DFlat(width, height, flat), nil
}
func SaveNoiseImage4D(arr *NDArray, z int, w int, outputPath string) error {
img, err := NoiseImage4D(arr, z, w)
if err != nil {
return err
}
return saveGrayPNG(img, outputPath)
}
func indexOf(shape []int, coords ...int) int {
stride := 1
idx := 0
for d := len(shape) - 1; d >= 0; d-- {
idx += coords[d] * stride
stride *= shape[d]
}
return idx
}
func noiseImageFrom2DFlat(width int, height int, flat []float64) *image.Gray {
img := image.NewGray(image.Rect(0, 0, width, height))
minValue := flat[0]
maxValue := flat[0]
for _, value := range flat[1:] {
if value < minValue {
minValue = value
}
if value > maxValue {
maxValue = value
}
}
if maxValue == minValue {
for x := 0; x < width; x++ {
for y := 0; y < height; y++ {
img.SetGray(x, y, color.Gray{Y: 128})
}
}
return img
}
scale := 255.0 / (maxValue - minValue)
idx := 0
for x := 0; x < width; x++ {
for y := 0; y < height; y++ {
gray := uint8((flat[idx] - minValue) * scale)
img.SetGray(x, y, color.Gray{Y: gray})
idx++
}
}
return img
}
func saveGrayPNG(img *image.Gray, outputPath string) error {
file, err := os.Create(outputPath)
if err != nil {
return err
}
defer file.Close()
return png.Encode(file, img)
}
Simplex Noise
Perlin噪声面临着一个问题:在2维空间时计算的是方形,有4个点;在3维空间计算的是方体,有8个点...当维度上升时,它的点呈指数型增长。但让我们仔细想一想,方形真的是必要的吗?不,其实2维空间用三角形就可以填满空间了;3维空间用4面体(4个点)就可以填满空间了...这样的话就可以将需要采样的点显著地降低,而且保证计算逻辑的几乎等价。
如果你也想到了这点,那么恭喜你,你发明了Simplex噪声。绝大多数Perlin噪声的计算方法都可以复用。
框架定义
定义Simplex的基本框架。
go
package simplex
import (
"fmt"
"sync"
"go_service3/algorithm"
)
type SimplexConfig struct {
algorithm.BaseConfig
Octaves int `json:"octaves,omitempty"`
Persistence float64 `json:"persistence,omitempty"`
Lacunarity float64 `json:"lacunarity,omitempty"`
}
type AlgorithmGenerator struct {
config *SimplexConfig
}
var registerOnce sync.Once
func Register() {
registerOnce.Do(func() {
algorithm.RegisterGenerator(SimplexConfig{}, NewGenerator)
algorithm.RegisterGenerator(&SimplexConfig{}, NewGenerator)
})
}
func NewGenerator(config any) (algorithm.AlgorithmGenerator, error) {
simplexConfig, err := algorithm.NewOwnedConfig(config, normalizeConfig)
if err != nil {
return nil, err
}
return &AlgorithmGenerator{
config: simplexConfig,
}, nil
}
func (g *AlgorithmGenerator) Generate(args ...any) (*algorithm.NDArray, error) {
if len(args) == 0 {
runCfg := *g.config
if err := normalizeConfig(&runCfg, true); err != nil {
return nil, err
}
return generate(runCfg), nil
}
runCfg, err := algorithm.ResolveConfig("simplex", *g.config, true, normalizeConfig, args...)
if err != nil {
return nil, err
}
return generate(runCfg), nil
}
func (g *AlgorithmGenerator) Reset(args ...any) error {
if len(args) == 0 {
return nil
}
nextCfg, err := algorithm.ResolveConfig("simplex", *g.config, false, normalizeConfig, args...)
if err != nil {
return err
}
*g.config = nextCfg
return nil
}
func normalizeConfig(cfg *SimplexConfig, requireShape bool) error {
if err := algorithm.NormalizeBaseConfig(&cfg.BaseConfig, requireShape); err != nil {
return err
}
if len(cfg.Shape) != 0 && len(cfg.Shape) != 2 {
return fmt.Errorf("simplex only supports 2D shape, got %d dimensions", len(cfg.Shape))
}
applyDefaults(cfg)
return nil
}
func applyDefaults(cfg *SimplexConfig) {
if cfg.Octaves <= 0 {
cfg.Octaves = 1
}
if cfg.Persistence == 0 {
cfg.Persistence = 0.5
}
if cfg.Lacunarity == 0 {
cfg.Lacunarity = 2.0
}
}
func generate(cfg SimplexConfig) *algorithm.NDArray {
return simplex_noise(&cfg)
}
梯度采样 & 图采样点噪声值计算(Simplex核心)
这里采用了12个方向,可以看到几乎符合我们之前对梯度采样的认知,可以根据实际情况灵活调整。
go
package simplex
import (
"go_service3/algorithm"
"math"
"math/rand"
"time"
)
func simplex_noise(cfg *SimplexConfig) *algorithm.NDArray {
grad_2d := gradient_at_dim2(&cfg.Seed)
width := cfg.Shape[0]
height := cfg.Shape[1]
noises := make([]float64, width*height)
var offsetx, offsety float64
if len(cfg.Offset) >= 2 {
offsetx = math.Abs(cfg.Offset[0])
offsety = math.Abs(cfg.Offset[1])
}
var stepx, stepy float64 = 1, 1
if len(cfg.Step) >= 2 {
stepx = math.Abs(cfg.Step[0])
stepy = math.Abs(cfg.Step[1])
}
octaves := cfg.Octaves
if octaves < 1 {
octaves = 1
}
persistence := cfg.Persistence
if persistence <= 0 {
persistence = 0.5
}
lacunarity := cfg.Lacunarity
if lacunarity <= 0 {
lacunarity = 2.0
}
idx := 0
for i := 0; i < width; i++ {
for j := 0; j < height; j++ {
u := float64(i)*stepx + offsetx
v := float64(j)*stepy + offsety
noises[idx] = fbm2D(u, v, octaves, persistence, lacunarity, grad_2d)
idx++
}
}
return &algorithm.NDArray{Shape: cfg.Shape, Data: noises}
}
func fbm2D(u float64, v float64, octaves int, persistence float64, lacunarity float64, grad_2d func(x, y int) [2]float64) float64 {
var noise_sum float64 = 0.0
var amp float64 = 1.0
var amp_sum float64 = 0.0
var freq float64 = 1.0
for o := 0; o < octaves; o++ {
noise_sum += baseSimplex2D(u*freq, v*freq, grad_2d) * amp
amp_sum += amp
amp *= persistence
freq *= lacunarity
}
return noise_sum / amp_sum
}
func baseSimplex2D(u float64, v float64, grad_2d func(x, y int) [2]float64) float64 {
var F2 = (math.Sqrt(3) - 1) / 2
var G2 = (3 - math.Sqrt(3)) / 6
s := (u + v) * F2
i := math.Floor(u + s)
j := math.Floor(v + s)
t := (i + j) * G2
X0 := i - t
Y0 := j - t
x0 := u - X0
y0 := v - Y0
var i1, j1 int
if x0 > y0 {
i1, j1 = 1, 0
} else {
i1, j1 = 0, 1
}
x1 := x0 - float64(i1) + G2
y1 := y0 - float64(j1) + G2
x2 := x0 - 1 + 2*G2
y2 := y0 - 1 + 2*G2
ii := int(i)
jj := int(j)
// 计算
t0 := 0.5 - x0*x0 - y0*y0
t1 := 0.5 - x1*x1 - y1*y1
t2 := 0.5 - x2*x2 - y2*y2
var contrib0, contrib1, contrib2 float64
if t0 >= 0 {
g0 := grad_2d(ii, jj)
contrib0 = t0 * t0 * t0 * t0 * (g0[0]*x0 + g0[1]*y0)
}
if t1 >= 0 {
g1 := grad_2d(ii+i1, jj+j1)
contrib1 = t1 * t1 * t1 * t1 * (g1[0]*x1 + g1[1]*y1)
}
if t2 >= 0 {
g2 := grad_2d(ii+1, jj+1)
contrib2 = t2 * t2 * t2 * t2 * (g2[0]*x2 + g2[1]*y2)
}
return 70 * (contrib0 + contrib1 + contrib2)
}
func gradient_at_dim2(seed *int64) func(x, y int) [2]float64 {
hash_func := lattice_hash(seed)
var direction [12][2]float64
direction[0] = [2]float64{1, 1}
direction[1] = [2]float64{-1, 1}
direction[2] = [2]float64{1, -1}
direction[3] = [2]float64{-1, -1}
direction[4] = [2]float64{1, 0}
direction[5] = [2]float64{-1, 0}
direction[6] = [2]float64{1, 0}
direction[7] = [2]float64{-1, 0}
direction[8] = [2]float64{0, 1}
direction[9] = [2]float64{0, -1}
direction[10] = [2]float64{0, 1}
direction[11] = [2]float64{0, -1}
return func(x, y int) [2]float64 {
return direction[hash_func(x, y)%12]
}
}
func lattice_hash(seed *int64) func(coords ...int) int {
var perm_512 [512]int
for i := range perm_512 {
perm_512[i] = i
}
var r *rand.Rand
if seed != nil && *seed > 0 {
r = rand.New(rand.NewSource(*seed))
} else {
r = rand.New(rand.NewSource(time.Now().UnixNano()))
}
r.Shuffle(512, func(i, j int) {
perm_512[i], perm_512[j] = perm_512[j], perm_512[i]
})
return func(coords ...int) int {
h := 0
for _, c := range coords {
h += perm_512[(h+c)%512]
}
return h
}
}
Value Noise
这个噪声简直就是以上两种噪声的简化版。其核心思想非常相似。
框架定义
定义Value的基本框架。
go
package value
import (
"math"
"sync"
"go_service3/algorithm"
)
// ValueConfig 包含了 Value 噪声生成所需的完整配置。
type ValueConfig struct {
algorithm.BaseConfig
Octaves int `json:"octaves,omitempty"` // 噪声的八度数(频率层级),用于分形叠加
Persistence float64 `json:"persistence,omitempty"` // 持续度,控制每个八度的振幅衰减系数
Lacunarity float64 `json:"lacunarity,omitempty"` // 间隙度,控制每个八度频率的增长系数
}
// AlgorithmGenerator 是 Value 算法的具体生成器实现。
type AlgorithmGenerator struct {
config *ValueConfig
}
var registerOnce sync.Once
// Register 显式注册 Value 生成器,避免依赖 init() 副作用。
func Register() {
registerOnce.Do(func() {
algorithm.RegisterGenerator(ValueConfig{}, NewGenerator)
algorithm.RegisterGenerator(&ValueConfig{}, NewGenerator)
})
}
// NewGenerator 创建一个 Value 生成器,并进行初始化配置归一化。
func NewGenerator(config any) (algorithm.AlgorithmGenerator, error) {
valueConfig, err := algorithm.NewOwnedConfig(config, normalizeConfig)
if err != nil {
return nil, err
}
return &AlgorithmGenerator{
config: valueConfig,
}, nil
}
// Generate 基于当前配置生成一份结果,args 只影响本次调用。
func (g *AlgorithmGenerator) Generate(args ...any) (*algorithm.NDArray, error) {
if len(args) == 0 {
runCfg := *g.config
if err := normalizeConfig(&runCfg, true); err != nil {
return nil, err
}
return generate(runCfg), nil
}
runCfg, err := algorithm.ResolveConfig("value", *g.config, true, normalizeConfig, args...)
if err != nil {
return nil, err
}
return generate(runCfg), nil
}
// Reset 将传入参数合并到当前配置,并永久写回生成器。
func (g *AlgorithmGenerator) Reset(args ...any) error {
if len(args) == 0 {
return nil
}
nextCfg, err := algorithm.ResolveConfig("value", *g.config, false, normalizeConfig, args...)
if err != nil {
return err
}
*g.config = nextCfg
return nil
}
func normalizeConfig(cfg *ValueConfig, requireShape bool) error {
if err := algorithm.NormalizeBaseConfig(&cfg.BaseConfig, requireShape); err != nil {
return err
}
applyDefaults(cfg)
return nil
}
func applyDefaults(cfg *ValueConfig) {
if cfg.Octaves <= 0 {
cfg.Octaves = 1
}
if cfg.Persistence == 0 {
cfg.Persistence = 0.5
}
if cfg.Lacunarity == 0 {
cfg.Lacunarity = 2.0
}
}
func generate(cfg ValueConfig) *algorithm.NDArray {
total := 1
for _, dimSize := range cfg.Shape {
total *= dimSize
}
data := make([]float64, total)
tables, amplitudes, maxValue := buildContributionTables(cfg)
fillGeneratedData(data, cfg.Shape, tables, amplitudes, maxValue)
shape := make([]int, len(cfg.Shape))
copy(shape, cfg.Shape)
return &algorithm.NDArray{Shape: shape, Data: data}
}
func buildContributionTables(cfg ValueConfig) ([][][]float64, []float64, float64) {
seedOffset := float64(cfg.Seed%100 + 1)
tables := make([][][]float64, cfg.Octaves)
amplitudes := make([]float64, cfg.Octaves)
frequency := 1.0
amplitude := 1.0
maxValue := 0.0
for octave := 0; octave < cfg.Octaves; octave++ {
amplitudes[octave] = amplitude
tables[octave] = make([][]float64, cfg.Dimension)
for dim, dimSize := range cfg.Shape {
offsetValue := 0.0
if len(cfg.Offset) != 0 {
offsetValue = cfg.Offset[dim]
}
stepValue := 1.0
if len(cfg.Step) != 0 {
stepValue = cfg.Step[dim]
}
base := offsetValue*frequency*seedOffset + float64(dim)*0.5
delta := stepValue * frequency * seedOffset
values := make([]float64, dimSize)
for i := range values {
values[i] = math.Sin(base + float64(i)*delta)
}
tables[octave][dim] = values
}
maxValue += amplitude * float64(cfg.Dimension)
amplitude *= cfg.Persistence
frequency *= cfg.Lacunarity
}
return tables, amplitudes, maxValue
}
func fillGeneratedData(data []float64, shape []int, tables [][][]float64, amplitudes []float64, maxValue float64) {
switch len(shape) {
case 1:
fill1D(data, shape, tables, amplitudes, maxValue)
case 2:
fill2D(data, shape, tables, amplitudes, maxValue)
case 3:
fill3D(data, shape, tables, amplitudes, maxValue)
default:
fillND(data, shape, tables, amplitudes, maxValue)
}
}
func fill1D(data []float64, shape []int, tables [][][]float64, amplitudes []float64, maxValue float64) {
if maxValue == 0 {
return
}
for x := 0; x < shape[0]; x++ {
total := 0.0
for octave, amplitude := range amplitudes {
total += tables[octave][0][x] * amplitude
}
data[x] = total / maxValue
}
}
func fill2D(data []float64, shape []int, tables [][][]float64, amplitudes []float64, maxValue float64) {
if maxValue == 0 {
return
}
index := 0
for x := 0; x < shape[0]; x++ {
for y := 0; y < shape[1]; y++ {
total := 0.0
for octave, amplitude := range amplitudes {
total += (tables[octave][0][x] + tables[octave][1][y]) * amplitude
}
data[index] = total / maxValue
index++
}
}
}
func fill3D(data []float64, shape []int, tables [][][]float64, amplitudes []float64, maxValue float64) {
if maxValue == 0 {
return
}
index := 0
for x := 0; x < shape[0]; x++ {
for y := 0; y < shape[1]; y++ {
for z := 0; z < shape[2]; z++ {
total := 0.0
for octave, amplitude := range amplitudes {
total += (tables[octave][0][x] + tables[octave][1][y] + tables[octave][2][z]) * amplitude
}
data[index] = total / maxValue
index++
}
}
}
}
func fillND(data []float64, shape []int, tables [][][]float64, amplitudes []float64, maxValue float64) {
if maxValue == 0 {
return
}
indices := make([]int, len(shape))
for i := range data {
total := 0.0
for octave, amplitude := range amplitudes {
octaveVal := 0.0
for dim, idx := range indices {
octaveVal += tables[octave][dim][idx]
}
total += octaveVal * amplitude
}
data[i] = total / maxValue
incrementIndices(indices, shape)
}
}
func incrementIndices(indices []int, shape []int) {
for dim := len(indices) - 1; dim >= 0; dim-- {
indices[dim]++
if indices[dim] < shape[dim] {
return
}
indices[dim] = 0
}
}
func cloneConfig(cfg ValueConfig) ValueConfig {
owned := cfg
if len(cfg.Shape) != 0 {
owned.Shape = make([]int, len(cfg.Shape))
copy(owned.Shape, cfg.Shape)
}
if len(cfg.Step) != 0 {
owned.Step = make([]float64, len(cfg.Step))
copy(owned.Step, cfg.Step)
}
if len(cfg.Offset) != 0 {
owned.Offset = make([]float64, len(cfg.Offset))
copy(owned.Offset, cfg.Offset)
}
return owned
}
格点采样 & 图采样点噪声值计算(Value核心)
Value没有采用之前的"向量"方式去计算每个点的值,而是直接赋予每一个点一个随机值作为初始化。更加简便,但带来的问题就是"变化比较割裂,不如前两种噪声平滑"。不过其实各有各的好处,太过平滑会让整个噪声图缺乏变化和极端情况,某些时候反而需要增加一些"割裂感"才能突出真实感。
这里对格点采样的方法值得借鉴,是很经典的控制随机性的方法。
go
package value
import (
"go_service3/algorithm"
"math"
)
func mix64(z uint64) uint64 {
z += 0x9e3779b97f4a7c15
z = (z ^ (z >> 30)) * 0xbf58476d1ce4e5b9
z = (z ^ (z >> 27)) * 0x94d049bb133111eb
return z ^ (z >> 31)
}
func hash2d(seed uint64, x, y int) uint64 {
ux := uint64(uint32(x))
uy := uint64(uint32(y))
h := seed
h ^= mix64(ux + 0x9e3779b97f4a7c15)
h ^= mix64(uy + 0xbf58476d1ce4e5b9)
return mix64(h)
}
func gradient_at_dim2(seed *int64) func(x, y int) float64 {
var seed_u uint64
if seed != nil {
seed_u = uint64(*seed)
}
return func(x, y int) float64 {
u := hash2d(seed_u, x, y)
v01 := float64(u>>11) * (1.0 / (1 << 53))
return v01*2.0 - 1.0
}
}
func baseValue2D(u, v float64, grad_2d func(x, y int) float64) float64 {
var noise float64 = 0.0
x0 := math.Floor(u)
y0 := math.Floor(v)
x1 := x0 + 1
y1 := y0 + 1
tx := u - float64(x0)
ty := v - float64(y0)
// 计算每一个角的值
v00 := grad_2d(int(x0), int(y0))
v01 := grad_2d(int(x0), int(y1))
v10 := grad_2d(int(x1), int(y0))
v11 := grad_2d(int(x1), int(y1))
wx := fade(tx)
wy := fade(ty)
ix0 := lerp(v00, v10, wx)
ix1 := lerp(v01, v11, wx)
noise = lerp(ix0, ix1, wy)
return noise
}
func fbm2D(u, v float64, octaves int, persistence float64, lacunarity float64, grad_2d func(x, y int) float64) float64 {
var noise_sum float64 = 0.0
var amp float64 = 1.0
var amp_sum float64 = 0.0
var freq float64 = 1.0
for o := 0; o < octaves; o++ {
noise_sum += baseValue2D(u*freq, v*freq, grad_2d) * amp
amp_sum += amp
amp *= persistence
freq *= lacunarity
}
return noise_sum / amp_sum
}
func lerp(a, b, t float64) float64 {
return a + (b-a)*t
}
func fade(t float64) float64 {
return t * t * t * (t*(t*6.0-15.0) + 10.0)
}
func value_noise(cfg *ValueConfig) *algorithm.NDArray {
grad_2d := gradient_at_dim2(&cfg.Seed)
width := cfg.Shape[0]
height := cfg.Shape[1]
noises := make([]float64, width*height)
var offsetx, offsety float64
if len(cfg.Offset) >= 2 {
offsetx = math.Abs(cfg.Offset[0])
offsety = math.Abs(cfg.Offset[1])
}
var stepx, stepy float64 = 1, 1
if len(cfg.Step) >= 2 {
stepx = math.Abs(cfg.Step[0])
stepy = math.Abs(cfg.Step[1])
}
octaves := cfg.Octaves
if octaves < 1 {
octaves = 1
}
persistence := cfg.Persistence
if persistence <= 0 {
persistence = 0.5
}
lacunarity := cfg.Lacunarity
if lacunarity <= 0 {
lacunarity = 2.0
}
idx := 0
for i := 0; i < width; i++ {
for j := 0; j < height; j++ {
u := float64(i)*stepx + offsetx
v := float64(j)*stepy + offsety
noises[idx] = fbm2D(u, v, octaves, persistence, lacunarity, grad_2d)
idx++
}
}
return &algorithm.NDArray{Shape: cfg.Shape, Data: noises}
}