From 3e2db3310a2d9a3d5d5f0d66ad90aae77c6adeee Mon Sep 17 00:00:00 2001 From: Grimsace Date: Tue, 27 Jan 2026 12:00:19 -0600 Subject: [PATCH] polished lake generation --- main.go | 41 ++++++++++---- settings.go | 15 ++--- terrain.go | 155 ++++++++++++++++++++++++++++++++-------------------- 3 files changed, 134 insertions(+), 77 deletions(-) diff --git a/main.go b/main.go index a59313e..6e1bf2f 100644 --- a/main.go +++ b/main.go @@ -21,7 +21,7 @@ func main() { if err != nil { log.Println("Error loading settings:", err) // Use default settings if loading fails - settings = &Settings{Detail: 1, Roughness: 0, Width: 300, Height: 300, Lakes: 0, LakeSize: 1} + settings = &Settings{Detail: 1, Roughness: 0, Width: 300, Height: 300, Lakes: 0, LakeSizeLower: 1, LakeSizeUpper: 5} } w.SetOnClosed(func() { @@ -40,7 +40,7 @@ func main() { // Initial image generation noiseImg := GenerateHeightmap(settings.Width, settings.Height, int(settings.Detail)) - canvasWithLakes, lakePixels := GenerateLakes(settings.Width, settings.Height, settings.Lakes, settings.LakeSize) + canvasWithLakes, lakePixels := GenerateLakes(settings.Width, settings.Height, settings.Lakes, settings.LakeSizeLower, settings.LakeSizeUpper, noiseImg) darkenedHeightmap := DarkenLakeAreas(noiseImg, lakePixels) compositeImg := ApplyRoughness(darkenedHeightmap, settings.Roughness) heightmapImg.Image = compositeImg @@ -70,13 +70,30 @@ func main() { } lakesSlider.SetValue(float64(settings.Lakes)) - lakeSizeLabel := widget.NewLabel(fmt.Sprintf("Lake Size: %.0f%%", settings.LakeSize)) - lakeSizeSlider := widget.NewSlider(1, 100) - lakeSizeSlider.OnChanged = func(val float64) { - settings.LakeSize = val - lakeSizeLabel.SetText(fmt.Sprintf("Lake Size: %.0f%%", settings.LakeSize)) + lakeSizeLowerLabel := widget.NewLabel(fmt.Sprintf("Min Lake Size: %.0f%%", settings.LakeSizeLower)) + lakeSizeLowerSlider := widget.NewSlider(1, 100) + lakeSizeUpperLabel := widget.NewLabel(fmt.Sprintf("Max Lake Size: %.0f%%", settings.LakeSizeUpper)) + lakeSizeUpperSlider := widget.NewSlider(1, 100) + + lakeSizeLowerSlider.OnChanged = func(val float64) { + settings.LakeSizeLower = val + if settings.LakeSizeLower > settings.LakeSizeUpper { + settings.LakeSizeUpper = settings.LakeSizeLower + lakeSizeUpperSlider.SetValue(settings.LakeSizeUpper) + } + lakeSizeLowerLabel.SetText(fmt.Sprintf("Min Lake Size: %.0f%%", settings.LakeSizeLower)) } - lakeSizeSlider.SetValue(settings.LakeSize) + lakeSizeLowerSlider.SetValue(settings.LakeSizeLower) + + lakeSizeUpperSlider.OnChanged = func(val float64) { + settings.LakeSizeUpper = val + if settings.LakeSizeUpper < settings.LakeSizeLower { + settings.LakeSizeLower = settings.LakeSizeUpper + lakeSizeLowerSlider.SetValue(settings.LakeSizeLower) + } + lakeSizeUpperLabel.SetText(fmt.Sprintf("Max Lake Size: %.0f%%", settings.LakeSizeUpper)) + } + lakeSizeUpperSlider.SetValue(settings.LakeSizeUpper) widthEntry := widget.NewEntry() widthEntry.SetText(strconv.Itoa(settings.Width)) @@ -98,7 +115,7 @@ func main() { generateBtn := widget.NewButton("Generate", func() { noiseImg := GenerateHeightmap(settings.Width, settings.Height, int(settings.Detail)) - canvasWithLakes, lakePixels := GenerateLakes(settings.Width, settings.Height, settings.Lakes, settings.LakeSize) + canvasWithLakes, lakePixels := GenerateLakes(settings.Width, settings.Height, settings.Lakes, settings.LakeSizeLower, settings.LakeSizeUpper, noiseImg) darkenedHeightmap := DarkenLakeAreas(noiseImg, lakePixels) compositeImg := ApplyRoughness(darkenedHeightmap, settings.Roughness) heightmapImg.Image = compositeImg @@ -114,8 +131,10 @@ func main() { roughnessSlider, lakesLabel, lakesSlider, - lakeSizeLabel, - lakeSizeSlider, + lakeSizeLowerLabel, + lakeSizeLowerSlider, + lakeSizeUpperLabel, + lakeSizeUpperSlider, )) imageTab := container.NewTabItem("Image", container.NewVBox( diff --git a/settings.go b/settings.go index 7c4642a..14bda0b 100644 --- a/settings.go +++ b/settings.go @@ -7,12 +7,13 @@ import ( ) type Settings struct { - Detail float64 `json:"detail"` - Roughness float64 `json:"roughness"` - Width int `json:"width"` - Height int `json:"height"` - Lakes int `json:"lakes"` - LakeSize float64 `json:"lake_size"` + Detail float64 `json:"detail"` + Roughness float64 `json:"roughness"` + Width int `json:"width"` + Height int `json:"height"` + Lakes int `json:"lakes"` + LakeSizeLower float64 `json:"lake_size_lower"` + LakeSizeUpper float64 `json:"lake_size_upper"` } func (s *Settings) Save() error { @@ -47,7 +48,7 @@ func LoadSettings() (*Settings, error) { file, err := os.Open(configFile) if err != nil { if os.IsNotExist(err) { - return &Settings{Detail: 1, Roughness: 0, Width: 300, Height: 300, Lakes: 0, LakeSize: 1}, nil + return &Settings{Detail: 1, Roughness: 0, Width: 300, Height: 300, Lakes: 0, LakeSizeLower: 1, LakeSizeUpper: 5}, nil } return nil, err } diff --git a/terrain.go b/terrain.go index 13bb0f3..4fc5140 100644 --- a/terrain.go +++ b/terrain.go @@ -44,82 +44,112 @@ func (pq *priorityQueue) Pop() interface{} { return item } -// GenerateLakes creates a specific number of lakes, each covering a specific percentage of the total image area. -// It uses a priority-based growth algorithm to ensure each lake is a single continuous component with organic edges. -func GenerateLakes(width, height, numLakes int, lakeSize float64) (image.Image, []image.Point) { +// GenerateLakes creates a specific number of lakes by dividing the image into chunks and placing one lake per chunk. +func GenerateLakes(width, height, numLakes int, lakeSizeLower, lakeSizeUpper float64, heightmap image.Image) (image.Image, []image.Point) { canvas := image.NewRGBA(image.Rect(0, 0, width, height)) draw.Draw(canvas, canvas.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src) - // Global map to track which pixels are already water to prevent duplicate darkening - isWater := make(map[image.Point]bool) - var allLakePixels []image.Point - - if numLakes <= 0 || lakeSize <= 0 { - return canvas, allLakePixels + if numLakes <= 0 || lakeSizeLower <= 0 { + return canvas, nil } + var allLakePixels []image.Point + randSrc := rand.New(rand.NewSource(time.Now().UnixNano())) + + // 1. Divide the image into a grid + gridDim := int(math.Ceil(math.Sqrt(float64(numLakes)))) + if gridDim == 0 { + return canvas, nil + } + chunkWidth := width / gridDim + chunkHeight := height / gridDim + if chunkWidth == 0 || chunkHeight == 0 { + return canvas, nil + } + + // 2. Create a list of chunk indices and shuffle them to randomize lake placement + chunkIndices := make([]int, gridDim*gridDim) + for i := range chunkIndices { + chunkIndices[i] = i + } + randSrc.Shuffle(len(chunkIndices), func(i, j int) { + chunkIndices[i], chunkIndices[j] = chunkIndices[j], chunkIndices[i] + }) + totalArea := float64(width * height) - targetPixelsPerLake := int(math.Round(totalArea * (lakeSize / 100.0))) - if targetPixelsPerLake <= 0 { - targetPixelsPerLake = 1 - } - - r := rand.New(rand.NewSource(time.Now().UnixNano())) - // One octave for maximum smoothness (no fractal detail that creates islands) - p := perlin.NewPerlin(2.0, 2.0, 1, r.Int63()) + p := perlin.NewPerlin(2.0, 2.0, 1, randSrc.Int63()) + // 3. Generate a lake in a subset of the chunks for i := 0; i < numLakes; i++ { - // Unique seed for this specific lake - seedX := r.Float64() * 10000.0 - seedY := r.Float64() * 10000.0 + if i >= len(chunkIndices) { + break + } - // Choose a random seed point - startPt := image.Point{X: r.Intn(width), Y: r.Intn(height)} + // Each lake gets a random size within the defined range + lakeSize := lakeSizeLower + if lakeSizeUpper > lakeSizeLower { + lakeSize = lakeSizeLower + randSrc.Float64()*(lakeSizeUpper-lakeSizeLower) + } + targetPixelsPerLake := int(math.Round(totalArea*(lakeSize/100.0))) / 2 + if targetPixelsPerLake <= 0 { + targetPixelsPerLake = 1 + } + chunkIndex := chunkIndices[i] + chunkGridX := chunkIndex % gridDim + chunkGridY := chunkIndex / gridDim + + chunkRect := image.Rect( + chunkGridX*chunkWidth, + chunkGridY*chunkHeight, + (chunkGridX+1)*chunkWidth, + (chunkGridY+1)*chunkHeight, + ) + + // Use the growth algorithm within the chunk pq := &priorityQueue{} heap.Init(pq) - - // track pixels already considered for THIS lake visited := make(map[image.Point]bool) - // Scale noise relative to expected lake size to maintain look + // Start near the center of the chunk + startPt := image.Point{ + X: chunkRect.Min.X + chunkWidth/2, + Y: chunkRect.Min.Y + chunkHeight/2, + } + // just in case the center is out of bounds + if !startPt.In(chunkRect) { + continue + } + + seedX := randSrc.Float64() * 10000.0 + seedY := randSrc.Float64() * 10000.0 radius := math.Sqrt(float64(targetPixelsPerLake) / math.Pi) - // Much lower frequency to avoid islands and thin peninsulas noiseFreq := 0.01 + (0.2 / (radius + 1.0)) - // Helper to calculate score getScore := func(pt image.Point) float64 { dx, dy := pt.X-startPt.X, pt.Y-startPt.Y dist := math.Sqrt(float64(dx*dx + dy*dy)) - - // Noise component noise := p.Noise2D(seedX+float64(dx)*noiseFreq, seedY+float64(dy)*noiseFreq) - - // Non-linear distance penalty: very low near center, increases rapidly at edge - // This makes the center much more "solid" - distPenalty := math.Pow(dist/radius, 2.0) - - return noise - distPenalty + distPenalty := math.Pow(dist/radius, 3.0) + luma, _, _, _ := heightmap.At(pt.X, pt.Y).RGBA() + heightmapVal := float64(luma) / 65535.0 + heightmapEffect := (0.5 - heightmapVal) * 1.5 + return noise - distPenalty + heightmapEffect } - // Push starting point heap.Push(pq, &lakePixel{point: startPt, score: getScore(startPt)}) visited[startPt] = true lakeCount := 0 for pq.Len() > 0 && lakeCount < targetPixelsPerLake { - // Pop the highest scoring frontier pixel current := heap.Pop(pq).(*lakePixel) - // Add to canvas and global list + // The pixel is valid, claim it. canvas.Set(current.point.X, current.point.Y, color.RGBA{R: 0, G: 0, B: 255, A: 255}) - if !isWater[current.point] { - isWater[current.point] = true - allLakePixels = append(allLakePixels, current.point) - } + allLakePixels = append(allLakePixels, current.point) lakeCount++ - // Add neighbors to frontier + // Add neighbors, constrained to the chunk rectangle for dy := -1; dy <= 1; dy++ { for dx := -1; dx <= 1; dx++ { if dx == 0 && dy == 0 { @@ -127,18 +157,15 @@ func GenerateLakes(width, height, numLakes int, lakeSize float64) (image.Image, } neighbor := image.Point{X: current.point.X + dx, Y: current.point.Y + dy} - // Bounds check - if neighbor.X < 0 || neighbor.X >= width || neighbor.Y < 0 || neighbor.Y >= height { + if !neighbor.In(chunkRect) || visited[neighbor] { continue } - if !visited[neighbor] { - visited[neighbor] = true - heap.Push(pq, &lakePixel{ - point: neighbor, - score: getScore(neighbor), - }) - } + visited[neighbor] = true + heap.Push(pq, &lakePixel{ + point: neighbor, + score: getScore(neighbor), + }) } } } @@ -153,14 +180,24 @@ func DarkenLakeAreas(heightmap image.Image, lakePixels []image.Point) image.Imag composite := image.NewRGBA(bounds) draw.Draw(composite, bounds, heightmap, image.Point{}, draw.Src) + // Create a map for quick lookup of lake pixels + isLake := make(map[image.Point]bool) for _, p := range lakePixels { - c := composite.At(p.X, p.Y) - r, g, b, a := c.RGBA() - // Darken by 15% - r = uint32(float64(r) * 0.85) - g = uint32(float64(g) * 0.85) - b = uint32(float64(b) * 0.85) - composite.Set(p.X, p.Y, color.RGBA64{R: uint16(r), G: uint16(g), B: uint16(b), A: uint16(a)}) + isLake[p] = true + } + + for y := bounds.Min.Y; y < bounds.Max.Y; y++ { + for x := bounds.Min.X; x < bounds.Max.X; x++ { + if !isLake[image.Point{X: x, Y: y}] { + c := composite.At(x, y) + r, g, b, a := c.RGBA() + // Darken by 15% + r = uint32(float64(r) * 0.85) + g = uint32(float64(g) * 0.85) + b = uint32(float64(b) * 0.85) + composite.Set(x, y, color.RGBA64{R: uint16(r), G: uint16(g), B: uint16(b), A: uint16(a)}) + } + } } return composite