feat: carve Jajce river into terrain

This commit is contained in:
Rijad Zuzo
2026-07-17 01:05:09 +02:00
parent 5356c34fd7
commit 145b90eb97
25 changed files with 603 additions and 74 deletions
+14 -8
View File
@@ -774,17 +774,23 @@ Completed:
stable-ID resource anchors in a resource-specific horizontal grid. Exact stable-ID resource anchors in a resource-specific horizontal grid. Exact
expanding queries preserve current scoring and selected targets while the expanding queries preserve current scoring and selected targets while the
reviewed 1,800-source case inspects about 4.4 candidates instead of 1,800. reviewed 1,800-source case inspects about 4.4 candidates instead of 1,800.
36. `Jajce River 03`: the deterministic Terrain3D source now carves a raised
waterfall shelf, upper stream, plunge basin, and curved downstream channel.
Shared watercourse profiles keep generated terrain and both water ribbons
aligned; softened foam/mist and a smaller rock frame make the falls part of
the landform. A matched Metal capture records the change, and the rebaked
navigation mesh passes terrain-height and runtime route checks.
Next: Next:
1. Sculpt a real Terrain3D river channel and waterfall shelf before adding a 1. Prove one bounded foliage/resource authoring workflow at a riverbank or
broad foliage asset pass. Use Terrain3D particles for nearby grass and forest edge. Use decorative Terrain3D/intentional instances for tree, fern,
intentional instancing for tree/fern/rock masses, then rebake and validate flower, and rock masses, but create simulation-owned finite resources only
the required village/resource routes. through selected stable-ID `ResourceNode` anchors.
2. During that first larger foliage pass, prove a bounded authoring workflow 2. Validate that villagers discover both nearby and farther authored anchors,
that places intentional stable-ID `ResourceNode` anchors beside decorative preserve reachable interaction points, and keep the visual batch inside a
Terrain3D instances. Add livestock only with a real identity and interaction measured presentation budget. Add livestock only with a real identity and
contract. interaction contract.
Do not start with GIS data, a full city, a large asset pack, or more NPC Do not start with GIS data, a full city, a large asset pack, or more NPC
mechanics. The next proof is a beautiful stage for the systems that already mechanics. The next proof is a beautiful stage for the systems that already
+19 -5
View File
@@ -889,15 +889,29 @@ Unload/rebind state authority, stable tie order, moved anchors, reservations,
player parity, and a deliberately far high-priority winner remain covered. See player parity, and a deliberately far high-priority winner remain covered. See
[Loaded Resource Discovery 01](benchmarks/LOADED_RESOURCE_DISCOVERY_01.md). [Loaded Resource Discovery 01](benchmarks/LOADED_RESOURCE_DISCOVERY_01.md).
The immediate next slice should pair this systems foundation with the authored The authored-watercourse slice is complete. `JajceWatercourse` now gives the
world: sculpt the real Terrain3D river channel and waterfall shelf, rebake and deterministic terrain generator and both water ribbons one shared profile; the
validate navigation, then use the first larger foliage/resource pass to prove a generated Terrain3D data contains an upper stream, raised waterfall shelf,
bounded stable-ID anchor placement workflow alongside decorative Terrain3D plunge basin, and curved downstream channel. A matched Metal capture records
instancing. Do not generalize the resource grid to people/buildings/events or the result, and the Terrain3D-derived navigation mesh was rebaked and validated
against required village/resource routes.
The immediate next slice should prove one bounded foliage/resource authoring
workflow in the real world: build a restrained riverbank or forest-edge cluster
with decorative Terrain3D/intentional instances, pair only selected harvestable
trees or berry patches with stable-ID `ResourceNode` anchors, and verify nearby
and farther loaded anchors remain discoverable through `ActiveWorldAdapter`.
Keep decorative instances out of simulation state, measure the bounded visual
cost, and do not generalize the resource grid to people/buildings/events or
introduce active/abstract LOD before those consumers need it. introduce active/abstract LOD before those consumers need it.
Recently completed: Recently completed:
- `Jajce River 03`: deterministic Terrain3D generation now carves the upper
stream, raised waterfall shelf, plunge basin, and curved downstream channel;
shared watercourse geometry keeps both water ribbons aligned, soft masked
mist replaces hard billboards, and the rebaked navigation mesh passes
terrain-height and runtime resource-route checks.
- Loaded-resource spatial discovery: a resource-specific active-world grid - Loaded-resource spatial discovery: a resource-specific active-world grid
preserves exact score winners and lifecycle authority while reducing the preserves exact score winners and lifecycle authority while reducing the
reviewed 1,800-anchor query from 6.45 ms to 46.94 µs. reviewed 1,800-anchor query from 6.45 ms to 46.94 µs.
+9 -6
View File
@@ -341,8 +341,9 @@ but retain far sources whenever their authoritative metadata can still win.
- a centered 512 m Terrain3D landscape split across four regions; - a centered 512 m Terrain3D landscape split across four regions;
- deterministic shaped terrain data and six game-owned surface layers; - deterministic shaped terrain data and six game-owned surface layers;
- a larger summit citadel, eight cascading houses, mill, full-span bridge, - a larger summit citadel, eight cascading houses, mill, full-span bridge, and
rock-framed waterfall, mist, smoke, and a Terrain3D-sampled river ribbon; a deterministic Terrain3D watercourse with an upper stream, raised waterfall
shelf, rock-framed drop, plunge basin, mist, and curved downstream ribbon;
- authored dirt path strips that make the current village task loop readable; - authored dirt path strips that make the current village task loop readable;
- compact silhouette props for the ridge landmark, guard, study, and rest - compact silhouette props for the ridge landmark, guard, study, and rest
sites, plus a physical pantry whose empty/low/stocked arrangement follows sites, plus a physical pantry whose empty/low/stocked arrangement follows
@@ -979,10 +980,12 @@ Raw samples and workload exclusions live in
[Simulation scaling baseline 02](benchmarks/SIMULATION_SCALING_BASELINE_02.md) [Simulation scaling baseline 02](benchmarks/SIMULATION_SCALING_BASELINE_02.md)
and [Loaded Resource Discovery 01](benchmarks/LOADED_RESOURCE_DISCOVERY_01.md). and [Loaded Resource Discovery 01](benchmarks/LOADED_RESOURCE_DISCOVERY_01.md).
The next slice should return to the authored Jajce stage: sculpt the real The authored Jajce watercourse and its Terrain3D-derived navigation rebake are
Terrain3D river channel and waterfall shelf, rebake/validate navigation, and complete. The next slice should prove a bounded stable-ID resource-anchor
use the following foliage expansion to prove a bounded stable-ID resource placement workflow beside decorative Terrain3D/intentional foliage instances
anchor placement workflow beside decorative Terrain3D instances. Do not yet at one riverbank or forest edge. It should validate nearby and farther loaded
resource discovery, reachable interaction points, and a measured presentation
budget without making decorative instances authoritative. Do not yet
generalize the resource grid or introduce active/abstract simulation LOD. generalize the resource grid or introduce active/abstract simulation LOD.
The remaining simulation-garden target still aims for: The remaining simulation-garden target still aims for:
+80
View File
@@ -0,0 +1,80 @@
# Jajce River 03
Captured: 2026-07-17
## Files
- [before overview](jajce_river_03_before.png)
- [after overview](jajce_river_03_after.png)
- `tools/capture_jajce_lookdev.gd`
- `tools/generate_jajce_terrain.gd`
- `tools/bake_jajce_navigation.gd`
The images use the same 1280×720 camera, fixed daylight value, warmup, and
Metal renderer. They are a matched checkpoint rather than two independently
composed beauty shots.
## What changed
The previous river was a turquoise presentation ribbon laid across a mostly
flat valley. Its waterfall was held by a large freestanding rock frame, so the
water feature read more like a prop than part of the landscape.
The deterministic Terrain3D source now creates:
- a raised shelf upstream of the falls;
- a shallow upper stream widening toward the lip;
- an approximately 12 m drop into a shaped plunge basin;
- a curved downstream channel with visible banks; and
- slope-driven rock texture around the drop and steeper channel edges.
The waterfall frame is smaller and nested into that landform. Its foam is more
restrained, and the mist uses a soft radial mask so individual particle quads
do not appear against the dark cliff. The result remains deliberately reduced:
citadel, roofs, shelf, falls, bridge, and river form one readable village-center
composition without attempting a literal reconstruction of Jajce.
## Deterministic authoring contract
`JajceWatercourse` owns the shared center lines, widths, bed heights, shelf
height, and water offset. `generate_jajce_terrain.gd` uses that profile to
produce the checked-in Terrain3D regions; `TerrainRiverRibbon.gd` uses the same
profile and samples the generated terrain for both water meshes. Changing the
watercourse therefore has one explicit regeneration and navigation-rebake
path instead of unrelated hand-tuned terrain and presentation coordinates.
This is presentation and interaction geometry only. No resource IDs,
simulation records, RNG streams, save data, or NPC decision rules changed.
## Validation
The focused watercourse scenario records an upper-stream height of 11.28 m, a
12.43 m shelf bank, and a -0.65 m plunge basin. It also checks multiple points
along the downstream bed and verifies both generated water ribbons remain
within 3 cm of the Terrain3D surface plus the shared water offset.
The navigation bake now contains 403 vertices and 620 polygons. The Jajce
scaffold verifies required paths reach their destinations and track the new
terrain heights, while the runtime integration scenario still completes real
player resource gathering with all 18 finite resource anchors loaded.
## Next visual/system proof
The next pass should author one bounded riverbank or forest-edge foliage mass,
using decorative Terrain3D/intentional instances for visual density and only a
small selected set of stable-ID `ResourceNode` anchors for finite harvestable
resources. Bank stones, ferns, flowers, and tree masses can improve the channel
edge during that proof. Bridge, buildings, and fortress geometry remain
prototype blockouts and should be replaced gradually rather than through a
broad asset dump.
## Capture command
```bash
HOME="$PWD/logs/quality/godot_profile" \
APPDATA="$PWD/logs/quality/godot_profile" \
LOCALAPPDATA="$PWD/logs/quality/godot_profile" \
/Applications/Godot.app/Contents/MacOS/Godot \
--path "$PWD" --script res://tools/capture_jajce_lookdev.gd -- \
--output=res://docs/baselines/jajce_river_03_after.png
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 742 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://r87qvthnulve"
path="res://.godot/imported/jajce_river_03_after.png-43f5610f2e4f42d23b2a77674f846c7e.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://docs/baselines/jajce_river_03_after.png"
dest_files=["res://.godot/imported/jajce_river_03_after.png-43f5610f2e4f42d23b2a77674f846c7e.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 730 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://2whbruuqbywc"
path="res://.godot/imported/jajce_river_03_before.png-1cca7b1c7659c390906ee3738cc8b95f.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://docs/baselines/jajce_river_03_before.png"
dest_files=["res://.godot/imported/jajce_river_03_before.png-1cca7b1c7659c390906ee3738cc8b95f.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+117
View File
@@ -0,0 +1,117 @@
extends SceneTree
const JajceWatercourse := preload("res://world/jajce/JajceWatercourse.gd")
var failures: Array[String] = []
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var camera := Camera3D.new()
camera.current = true
root.add_child(camera)
var world: Node3D = load("res://world/jajce/JajceWorld.tscn").instantiate()
root.add_child(world)
await process_frame
for _frame in 4:
await physics_frame
var terrain := world.get_node("TerrainRoot/Terrain3D") as Terrain3D
_check(terrain != null, "Jajce watercourse validation requires Terrain3D")
if terrain == null:
_finish()
return
var shelf_z := -36.0
var shelf_center_x := JajceWatercourse.upper_stream_center_x(shelf_z)
var shelf_half_width := JajceWatercourse.upper_stream_half_width(shelf_z)
var shelf_stream_height := _terrain_height(terrain, shelf_center_x, shelf_z)
var shelf_bank_height := _terrain_height(
terrain, shelf_center_x + shelf_half_width + 4.0, shelf_z
)
var plunge_height := _terrain_height(
terrain, JajceWatercourse.PLUNGE_POOL_CENTER.x, JajceWatercourse.PLUNGE_POOL_CENTER.y
)
_check(shelf_stream_height >= 10.8, "Upper stream should remain on the raised waterfall shelf")
_check(
shelf_bank_height - shelf_stream_height >= 0.45,
"Upper stream should be visibly carved below its shelf bank"
)
_check(
shelf_stream_height - plunge_height >= 10.0,
"Waterfall shelf should create a readable drop into the plunge basin"
)
var downstream_samples := PackedFloat32Array([-10.0, 12.0, 34.0])
for world_z in downstream_samples:
var center_x := JajceWatercourse.downstream_center_x(world_z)
var half_width := JajceWatercourse.downstream_half_width(world_z)
var channel_height := _terrain_height(terrain, center_x, world_z)
var left_bank_height := _terrain_height(terrain, center_x - half_width - 5.0, world_z)
var right_bank_height := _terrain_height(terrain, center_x + half_width + 5.0, world_z)
_check(
channel_height <= JajceWatercourse.downstream_bed_height(world_z) + 0.08,
"Downstream bed should follow its authored profile at z=%.1f" % world_z
)
_check(
maxf(left_bank_height, right_bank_height) - channel_height >= 0.45,
"Downstream channel should read below a natural bank at z=%.1f" % world_z
)
var river_surface := world.get_node("WaterRoot/RiverSurface") as Node3D
_check_ribbon_tracks_terrain(
terrain, river_surface.get_node("RiverRibbon") as MeshInstance3D, "Downstream water"
)
_check_ribbon_tracks_terrain(
terrain, river_surface.get_node("UpperRiverRibbon") as MeshInstance3D, "Upper-stream water"
)
print(
(
"[TEST] Jajce watercourse samples | shelf=%.2f bank=%.2f pool=%.2f"
% [shelf_stream_height, shelf_bank_height, plunge_height]
)
)
_finish()
func _terrain_height(terrain: Terrain3D, world_x: float, world_z: float) -> float:
return terrain.data.get_height(Vector3(world_x, 0.0, world_z))
func _check_ribbon_tracks_terrain(
terrain: Terrain3D, ribbon: MeshInstance3D, label: String
) -> void:
_check(ribbon != null and ribbon.mesh is ArrayMesh, "%s ribbon should be generated" % label)
if ribbon == null or not ribbon.mesh is ArrayMesh:
return
var arrays := ribbon.mesh.surface_get_arrays(0)
var vertices: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
_check(not vertices.is_empty(), "%s ribbon should contain vertices" % label)
for vertex_index in range(0, vertices.size(), maxi(1, vertices.size() / 12)):
var world_vertex := ribbon.to_global(vertices[vertex_index])
var terrain_height := terrain.data.get_height(world_vertex)
if is_nan(terrain_height):
failures.append("%s ribbon sampled outside Terrain3D" % label)
return
if absf(world_vertex.y - terrain_height - JajceWatercourse.WATER_SURFACE_OFFSET) > 0.03:
failures.append("%s ribbon should hug the carved terrain" % label)
return
func _check(condition: bool, message: String) -> void:
if not condition:
failures.append(message)
func _finish() -> void:
if failures.is_empty():
print("[TEST] Jajce watercourse terrain passed")
quit(0)
return
for failure in failures:
push_error("[TEST] " + failure)
quit(1)
@@ -0,0 +1 @@
uid://bs5xfsxii5j8w
+27 -1
View File
@@ -1,5 +1,7 @@
extends SceneTree extends SceneTree
const JajceWatercourse := preload("res://world/jajce/JajceWatercourse.gd")
var failures: Array[String] = [] var failures: Array[String] = []
@@ -213,7 +215,7 @@ func _run() -> void:
"Waterfall mist particles should have visible billboard geometry" "Waterfall mist particles should have visible billboard geometry"
) )
_check( _check(
waterfall_mist.position.distance_to(Vector3(25.0, 2.05, -24.0)) < 0.01, waterfall_mist.position.distance_to(Vector3(25.0, 0.65, -21.5)) < 0.01,
"Waterfall mist should stay at the waterfall base instead of obscuring the village" "Waterfall mist should stay at the waterfall base instead of obscuring the village"
) )
_check(world.has_node("WaterRoot/RiverSurface"), "WaterRoot should include a river surface") _check(world.has_node("WaterRoot/RiverSurface"), "WaterRoot should include a river surface")
@@ -222,10 +224,34 @@ func _run() -> void:
river_ribbon.mesh is ArrayMesh, river_ribbon.mesh is ArrayMesh,
"River should build a lightweight ribbon from the current Terrain3D surface" "River should build a lightweight ribbon from the current Terrain3D surface"
) )
var upper_river_ribbon := (
world.get_node("WaterRoot/RiverSurface/UpperRiverRibbon") as MeshInstance3D
)
_check(
upper_river_ribbon.mesh is ArrayMesh,
"Waterfall shelf should expose a terrain-following upper stream"
)
_check( _check(
world.has_node("WaterRoot/RiverSurface/PlungePool"), world.has_node("WaterRoot/RiverSurface/PlungePool"),
"River should widen into a readable plunge pool beneath the waterfall" "River should widen into a readable plunge pool beneath the waterfall"
) )
var plunge_pool := world.get_node("WaterRoot/RiverSurface/PlungePool") as MeshInstance3D
var expected_pool_height: float = terrain.data.get_height(
Vector3(JajceWatercourse.PLUNGE_POOL_CENTER.x, 0.0, JajceWatercourse.PLUNGE_POOL_CENTER.y)
)
_check(
(
absf(
(
plunge_pool.global_position.y
- expected_pool_height
- JajceWatercourse.WATER_SURFACE_OFFSET
)
)
< 0.03
),
"Plunge-pool water should follow the carved basin height"
)
var environment: Environment = ( var environment: Environment = (
(world.get_node("WorldEnvironment") as WorldEnvironment).environment (world.get_node("WorldEnvironment") as WorldEnvironment).environment
) )
+44 -3
View File
@@ -5,6 +5,7 @@ const HALF_SIZE := MAP_SIZE / 2
const HEIGHT_SCALE := 40.0 const HEIGHT_SCALE := 40.0
const DATA_DIRECTORY := "res://terrain/jajce/data" const DATA_DIRECTORY := "res://terrain/jajce/data"
const TEXTURE_DIRECTORY := "res://terrain/jajce/textures" const TEXTURE_DIRECTORY := "res://terrain/jajce/textures"
const Watercourse := preload("res://world/jajce/JajceWatercourse.gd")
func _initialize() -> void: func _initialize() -> void:
@@ -41,13 +42,15 @@ func _generate() -> void:
var southern_hill := ( var southern_hill := (
11.0 * _elliptical_peak(world_x, world_z, -35.0, -125.0, 130.0, 85.0) 11.0 * _elliptical_peak(world_x, world_z, -35.0, -125.0, 130.0, 85.0)
) )
var river_trench := -5.0 * exp(-pow((world_x - 25.0) / 18.0, 2.0))
var broad_slope := maxf(0.0, (-world_x - 30.0) * 0.035) var broad_slope := maxf(0.0, (-world_x - 30.0) * 0.035)
var detail := noise.get_noise_2d(world_x, world_z) * 2.4 var detail := noise.get_noise_2d(world_x, world_z) * 2.4
var height := ( var height := (
(ridge + eastern_hill + southern_hill + river_trench + broad_slope + detail) (ridge + eastern_hill + southern_hill + broad_slope + detail) * outside_play_area
* outside_play_area
) )
height = _shape_waterfall_shelf(height, world_x, world_z, detail)
height = _shape_upper_stream(height, world_x, world_z)
height = _shape_plunge_pool(height, world_x, world_z)
height = _shape_downstream_channel(height, world_x, world_z)
height_map.set_pixel(image_x, image_z, Color(height / HEIGHT_SCALE, 0.0, 0.0, 1.0)) height_map.set_pixel(image_x, image_z, Color(height / HEIGHT_SCALE, 0.0, 0.0, 1.0))
terrain.data.import_images( terrain.data.import_images(
@@ -64,6 +67,44 @@ func _elliptical_peak(
return exp(-(pow((x - center_x) / width, 2.0) + pow((z - center_z) / depth, 2.0))) return exp(-(pow((x - center_x) / width, 2.0) + pow((z - center_z) / depth, 2.0)))
func _shape_waterfall_shelf(height: float, x: float, z: float, detail: float) -> float:
var horizontal_mask := 1.0 - smoothstep(10.0, 28.0, absf(x - Watercourse.WATERFALL_CENTER.x))
var upstream_mask := smoothstep(-76.0, -54.0, z)
var drop_mask := 1.0 - smoothstep(-25.0, -21.0, z)
var shelf_mask := horizontal_mask * upstream_mask * drop_mask
var shelf_height := Watercourse.SHELF_TOP_HEIGHT + detail * 0.08
return lerpf(height, maxf(height, shelf_height), shelf_mask)
func _shape_upper_stream(height: float, x: float, z: float) -> float:
if z < Watercourse.UPPER_STREAM_START_Z or z > Watercourse.UPPER_STREAM_END_Z:
return height
var center_x := Watercourse.upper_stream_center_x(z)
var half_width := Watercourse.upper_stream_half_width(z)
var channel_mask := 1.0 - smoothstep(half_width, half_width + 3.2, absf(x - center_x))
var target_height := minf(height, Watercourse.upper_stream_bed_height(z))
return lerpf(height, target_height, channel_mask)
func _shape_plunge_pool(height: float, x: float, z: float) -> float:
var offset := Vector2(x, z) - Watercourse.PLUNGE_POOL_CENTER
var elliptical_distance := Vector2(offset.x / 10.5, offset.y / 7.8).length()
var basin_mask := 1.0 - smoothstep(0.68, 1.15, elliptical_distance)
var cliff_front_mask := smoothstep(-25.0, -20.5, z)
var target_height := minf(height, Watercourse.PLUNGE_POOL_BED_HEIGHT)
return lerpf(height, target_height, basin_mask * cliff_front_mask)
func _shape_downstream_channel(height: float, x: float, z: float) -> float:
if z < Watercourse.DOWNSTREAM_START_Z or z > Watercourse.DOWNSTREAM_END_Z:
return height
var center_x := Watercourse.downstream_center_x(z)
var half_width := Watercourse.downstream_half_width(z)
var bank_mask := 1.0 - smoothstep(half_width, half_width + 4.5, absf(x - center_x))
var target_height := minf(height, Watercourse.downstream_bed_height(z))
return lerpf(height, target_height, bank_mask)
func _generate_texture( func _generate_texture(
file_name: String, dark_color: Color, light_color: Color, seed_value: int file_name: String, dark_color: Color, light_color: Color, seed_value: int
) -> void: ) -> void:
File diff suppressed because one or more lines are too long
+49
View File
@@ -0,0 +1,49 @@
class_name JajceWatercourse
extends RefCounted
const WATERFALL_CENTER := Vector2(25.0, -24.0)
const SHELF_TOP_HEIGHT := 12.4
const UPPER_STREAM_START_Z := -58.0
const UPPER_STREAM_END_Z := -25.0
const PLUNGE_POOL_CENTER := Vector2(25.0, -19.5)
const PLUNGE_POOL_BED_HEIGHT := -0.65
const DOWNSTREAM_START_Z := -20.0
const DOWNSTREAM_END_Z := 42.0
const WATER_SURFACE_OFFSET := 0.32
static func downstream_progress(world_z: float) -> float:
return clampf(inverse_lerp(DOWNSTREAM_START_Z, DOWNSTREAM_END_Z, world_z), 0.0, 1.0)
static func downstream_center_x(world_z: float) -> float:
var progress := downstream_progress(world_z)
var downstream_curve := smoothstep(0.58, 1.0, progress) * 12.0
return WATERFALL_CENTER.x + sin(progress * TAU) * 1.4 + downstream_curve
static func downstream_half_width(world_z: float) -> float:
var progress := downstream_progress(world_z)
var base_width := lerpf(4.8, 3.8, smoothstep(0.0, 0.62, progress))
return base_width + smoothstep(0.78, 1.0, progress) * 0.8
static func downstream_bed_height(world_z: float) -> float:
return lerpf(-0.55, -1.3, downstream_progress(world_z))
static func upper_stream_progress(world_z: float) -> float:
return clampf(inverse_lerp(UPPER_STREAM_START_Z, UPPER_STREAM_END_Z, world_z), 0.0, 1.0)
static func upper_stream_center_x(world_z: float) -> float:
var progress := upper_stream_progress(world_z)
return WATERFALL_CENTER.x + sin(progress * PI * 1.6) * 0.9
static func upper_stream_half_width(world_z: float) -> float:
return lerpf(2.8, 4.4, smoothstep(0.55, 1.0, upper_stream_progress(world_z)))
static func upper_stream_bed_height(world_z: float) -> float:
return lerpf(11.55, 11.15, upper_stream_progress(world_z))
+1
View File
@@ -0,0 +1 @@
uid://dl3523bi00bto
+50 -20
View File
@@ -1,15 +1,16 @@
extends Node3D extends Node3D
const Watercourse := preload("res://world/jajce/JajceWatercourse.gd")
@export var terrain_path := NodePath("../../TerrainRoot/Terrain3D") @export var terrain_path := NodePath("../../TerrainRoot/Terrain3D")
@export var river_material: Material @export var river_material: Material
@export_range(6.0, 20.0, 0.5) var river_width := 8.0
@export_range(2, 12, 1) var width_segments := 6 @export_range(2, 12, 1) var width_segments := 6
@export_range(8, 96, 1) var length_segments := 36 @export_range(8, 96, 1) var length_segments := 36
@export var local_start_z := -25.0 @export_range(8, 64, 1) var upper_length_segments := 18
@export var local_end_z := 40.0 @export_range(0.05, 0.5, 0.01) var water_offset := Watercourse.WATER_SURFACE_OFFSET
@export_range(0.05, 0.5, 0.01) var water_offset := 0.18
@onready var river_ribbon: MeshInstance3D = $RiverRibbon @onready var river_ribbon: MeshInstance3D = $RiverRibbon
@onready var upper_river_ribbon: MeshInstance3D = $UpperRiverRibbon
func _ready() -> void: func _ready() -> void:
@@ -22,22 +23,48 @@ func _build_terrain_ribbon() -> void:
push_warning("TerrainRiverRibbon: terrain or material is unavailable") push_warning("TerrainRiverRibbon: terrain or material is unavailable")
return return
river_ribbon.mesh = _build_ribbon(
terrain,
Watercourse.DOWNSTREAM_START_Z,
Watercourse.DOWNSTREAM_END_Z,
length_segments,
false
)
upper_river_ribbon.mesh = _build_ribbon(
terrain,
Watercourse.UPPER_STREAM_START_Z,
Watercourse.UPPER_STREAM_END_Z,
upper_length_segments,
true
)
_align_pool_to_terrain(terrain)
func _build_ribbon(
terrain: Terrain3D, start_z: float, end_z: float, segment_count: int, is_upper: bool
) -> ArrayMesh:
var vertices := PackedVector3Array() var vertices := PackedVector3Array()
var normals := PackedVector3Array() var normals := PackedVector3Array()
var uvs := PackedVector2Array() var uvs := PackedVector2Array()
var indices := PackedInt32Array() var indices := PackedInt32Array()
var row_size := width_segments + 1 var row_size := width_segments + 1
for z_index in length_segments + 1: for z_index in segment_count + 1:
var progress := float(z_index) / float(length_segments) var progress := float(z_index) / float(segment_count)
var local_z := lerpf(local_start_z, local_end_z, progress) var world_z := lerpf(start_z, end_z, progress)
var downstream_curve := smoothstep(0.58, 1.0, progress) * 12.0 var center_x := (
var center_offset := sin(progress * TAU) * 1.4 + downstream_curve Watercourse.upper_stream_center_x(world_z)
var downstream_taper := lerpf(1.0, 0.45, smoothstep(0.65, 1.0, progress)) if is_upper
var half_width := river_width * lerpf(0.52, 0.45, progress) * downstream_taper else Watercourse.downstream_center_x(world_z)
)
var half_width := (
Watercourse.upper_stream_half_width(world_z)
if is_upper
else Watercourse.downstream_half_width(world_z)
)
for x_index in width_segments + 1: for x_index in width_segments + 1:
var across := float(x_index) / float(width_segments) var across := float(x_index) / float(width_segments)
var local_x := center_offset + lerpf(-half_width, half_width, across) var world_x := center_x + lerpf(-half_width, half_width, across)
var world_sample := to_global(Vector3(local_x, 0.0, local_z)) var world_sample := Vector3(world_x, 0.0, world_z)
var terrain_height := terrain.data.get_height(world_sample) var terrain_height := terrain.data.get_height(world_sample)
if is_nan(terrain_height): if is_nan(terrain_height):
terrain_height = global_position.y terrain_height = global_position.y
@@ -47,7 +74,7 @@ func _build_terrain_ribbon() -> void:
normals.append(Vector3.UP) normals.append(Vector3.UP)
uvs.append(Vector2(across, progress)) uvs.append(Vector2(across, progress))
for z_index in length_segments: for z_index in segment_count:
for x_index in width_segments: for x_index in width_segments:
var top_left := z_index * row_size + x_index var top_left := z_index * row_size + x_index
var top_right := top_left + 1 var top_right := top_left + 1
@@ -66,17 +93,20 @@ func _build_terrain_ribbon() -> void:
var mesh := ArrayMesh.new() var mesh := ArrayMesh.new()
mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays) mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
mesh.surface_set_material(0, river_material) mesh.surface_set_material(0, river_material)
river_ribbon.mesh = mesh return mesh
_align_pool_to_terrain(terrain)
func _align_pool_to_terrain(terrain: Terrain3D) -> void: func _align_pool_to_terrain(terrain: Terrain3D) -> void:
var pool := $PlungePool as MeshInstance3D var pool := $PlungePool as MeshInstance3D
var pool_sample := to_global(Vector3(pool.position.x, 0.0, pool.position.z)) var pool_sample := Vector3(
Watercourse.PLUNGE_POOL_CENTER.x, 0.0, Watercourse.PLUNGE_POOL_CENTER.y
)
var pool_height := terrain.data.get_height(pool_sample) var pool_height := terrain.data.get_height(pool_sample)
if is_nan(pool_height): if is_nan(pool_height):
return return
var local_surface := to_local(Vector3(pool_sample.x, pool_height + water_offset, pool_sample.z)) var local_surface := to_local(Vector3(pool_sample.x, pool_height + water_offset, pool_sample.z))
for node_name in [&"PlungePool", &"FoamRing", &"FoamAtFall"]: pool.position = local_surface
var surface := get_node(NodePath(node_name)) as MeshInstance3D var foam_ring := $FoamRing as MeshInstance3D
surface.position.y = local_surface.y foam_ring.position = local_surface + Vector3(0.0, 0.1, 0.0)
var foam_at_fall := $FoamAtFall as MeshInstance3D
foam_at_fall.position = local_surface + Vector3(0.0, 0.13, -2.7)
+8 -8
View File
@@ -17,22 +17,22 @@ rings = 2
[sub_resource type="StandardMaterial3D" id="Material_foam"] [sub_resource type="StandardMaterial3D" id="Material_foam"]
transparency = 1 transparency = 1
shading_mode = 0 shading_mode = 0
albedo_color = Color(0.8, 0.93, 0.86, 0.46) albedo_color = Color(0.8, 0.93, 0.86, 0.22)
emission_enabled = true emission_enabled = true
emission = Color(0.34, 0.56, 0.48, 1) emission = Color(0.34, 0.56, 0.48, 1)
emission_energy_multiplier = 0.18 emission_energy_multiplier = 0.06
roughness = 0.74 roughness = 0.74
[sub_resource type="TorusMesh" id="Mesh_foam_ring"] [sub_resource type="TorusMesh" id="Mesh_foam_ring"]
material = SubResource("Material_foam") material = SubResource("Material_foam")
inner_radius = 4.8 inner_radius = 5.15
outer_radius = 5.65 outer_radius = 5.5
rings = 28 rings = 28
ring_segments = 10 ring_segments = 10
[sub_resource type="PlaneMesh" id="Mesh_foam_patch"] [sub_resource type="PlaneMesh" id="Mesh_foam_patch"]
material = SubResource("Material_foam") material = SubResource("Material_foam")
size = Vector2(10.5, 2.2) size = Vector2(6, 1.5)
subdivide_width = 8 subdivide_width = 8
subdivide_depth = 2 subdivide_depth = 2
@@ -44,18 +44,18 @@ river_material = SubResource("ShaderMaterial_water")
[node name="RiverRibbon" type="MeshInstance3D" parent="."] [node name="RiverRibbon" type="MeshInstance3D" parent="."]
cast_shadow = 0 cast_shadow = 0
[node name="UpperRiverRibbon" type="MeshInstance3D" parent="."]
cast_shadow = 0
[node name="PlungePool" type="MeshInstance3D" parent="."] [node name="PlungePool" type="MeshInstance3D" parent="."]
position = Vector3(0, -0.02, -25)
scale = Vector3(1.25, 1, 0.82) scale = Vector3(1.25, 1, 0.82)
mesh = SubResource("Mesh_plunge_pool") mesh = SubResource("Mesh_plunge_pool")
[node name="FoamRing" type="MeshInstance3D" parent="."] [node name="FoamRing" type="MeshInstance3D" parent="."]
position = Vector3(0, 0.09, -25)
scale = Vector3(1.25, 0.12, 0.82) scale = Vector3(1.25, 0.12, 0.82)
cast_shadow = 0 cast_shadow = 0
mesh = SubResource("Mesh_foam_ring") mesh = SubResource("Mesh_foam_ring")
[node name="FoamAtFall" type="MeshInstance3D" parent="."] [node name="FoamAtFall" type="MeshInstance3D" parent="."]
position = Vector3(0, 0.12, -25.8)
cast_shadow = 0 cast_shadow = 0
mesh = SubResource("Mesh_foam_patch") mesh = SubResource("Mesh_foam_patch")
+38 -14
View File
@@ -1,4 +1,4 @@
[gd_scene load_steps=10 format=3] [gd_scene load_steps=12 format=3]
[ext_resource type="Shader" path="res://world/jajce/materials/waterfall.gdshader" id="1_waterfall_shader"] [ext_resource type="Shader" path="res://world/jajce/materials/waterfall.gdshader" id="1_waterfall_shader"]
@@ -7,7 +7,7 @@ shader = ExtResource("1_waterfall_shader")
[sub_resource type="QuadMesh" id="Mesh_waterfall_body"] [sub_resource type="QuadMesh" id="Mesh_waterfall_body"]
material = SubResource("ShaderMaterial_waterfall") material = SubResource("ShaderMaterial_waterfall")
size = Vector2(10.8, 14) size = Vector2(9.6, 11.4)
subdivide_width = 18 subdivide_width = 18
subdivide_depth = 28 subdivide_depth = 28
@@ -32,50 +32,74 @@ size = Vector3(4.8, 5.5, 3.4)
[sub_resource type="BoxMesh" id="Mesh_lip"] [sub_resource type="BoxMesh" id="Mesh_lip"]
material = SubResource("Material_rock_moss") material = SubResource("Material_rock_moss")
size = Vector3(11.6, 0.7, 3.2) size = Vector3(10.4, 0.65, 3.0)
[sub_resource type="StandardMaterial3D" id="Material_lip_foam"]
transparency = 1
shading_mode = 0
albedo_color = Color(0.82, 0.95, 0.87, 0.72)
emission_enabled = true
emission = Color(0.46, 0.72, 0.62, 1)
emission_energy_multiplier = 0.22
roughness = 0.62
[sub_resource type="PlaneMesh" id="Mesh_lip_foam"]
material = SubResource("Material_lip_foam")
size = Vector2(9.2, 1.45)
subdivide_width = 8
subdivide_depth = 2
[node name="WaterfallBody" type="Node3D"] [node name="WaterfallBody" type="Node3D"]
position = Vector3(25, 8.9, -24) position = Vector3(25, 5.8, -24)
[node name="RockFrame" type="Node3D" parent="."] [node name="RockFrame" type="Node3D" parent="."]
[node name="WestCliff" type="MeshInstance3D" parent="RockFrame"] [node name="WestCliff" type="MeshInstance3D" parent="RockFrame"]
position = Vector3(-7.2, -0.45, -1) position = Vector3(-6.1, -0.15, -0.9)
rotation_degrees = Vector3(0, 8, -3) rotation_degrees = Vector3(0, 8, -3)
scale = Vector3(0.72, 0.86, 0.8)
mesh = SubResource("Mesh_cliff_side") mesh = SubResource("Mesh_cliff_side")
[node name="EastCliff" type="MeshInstance3D" parent="RockFrame"] [node name="EastCliff" type="MeshInstance3D" parent="RockFrame"]
position = Vector3(7.2, -0.25, -1.2) position = Vector3(6.2, 0.05, -1.1)
rotation_degrees = Vector3(0, -7, 4) rotation_degrees = Vector3(0, -7, 4)
scale = Vector3(0.68, 0.88, 0.76)
mesh = SubResource("Mesh_cliff_side") mesh = SubResource("Mesh_cliff_side")
[node name="WestCrownRock" type="MeshInstance3D" parent="RockFrame"] [node name="WestCrownRock" type="MeshInstance3D" parent="RockFrame"]
position = Vector3(-5.0, 7.45, -1.25) position = Vector3(-4.9, 5.8, -1.2)
rotation_degrees = Vector3(8, -14, -11) rotation_degrees = Vector3(8, -14, -11)
scale = Vector3(0.85, 0.62, 0.9) scale = Vector3(0.82, 0.58, 0.88)
mesh = SubResource("Mesh_rock_lobe") mesh = SubResource("Mesh_rock_lobe")
[node name="EastCrownRock" type="MeshInstance3D" parent="RockFrame"] [node name="EastCrownRock" type="MeshInstance3D" parent="RockFrame"]
position = Vector3(5.1, 7.55, -1.4) position = Vector3(4.8, 5.9, -1.35)
rotation_degrees = Vector3(-6, 12, 9) rotation_degrees = Vector3(-6, 12, 9)
scale = Vector3(0.82, 0.6, 0.86) scale = Vector3(0.78, 0.58, 0.84)
mesh = SubResource("Mesh_rock_lobe") mesh = SubResource("Mesh_rock_lobe")
[node name="WestLowerRock" type="MeshInstance3D" parent="RockFrame"] [node name="WestLowerRock" type="MeshInstance3D" parent="RockFrame"]
position = Vector3(-5.9, -4.8, 0.1) position = Vector3(-5.35, -5.0, 0.15)
rotation_degrees = Vector3(7, 14, 12) rotation_degrees = Vector3(7, 14, 12)
scale = Vector3(0.88, 0.72, 0.9)
mesh = SubResource("Mesh_rock_lobe") mesh = SubResource("Mesh_rock_lobe")
[node name="EastLowerRock" type="MeshInstance3D" parent="RockFrame"] [node name="EastLowerRock" type="MeshInstance3D" parent="RockFrame"]
position = Vector3(6.0, -4.4, 0) position = Vector3(5.45, -4.75, 0)
rotation_degrees = Vector3(-4, -12, -9) rotation_degrees = Vector3(-4, -12, -9)
scale = Vector3(0.9, 0.74, 0.88)
mesh = SubResource("Mesh_rock_lobe") mesh = SubResource("Mesh_rock_lobe")
[node name="RiverLip" type="MeshInstance3D" parent="RockFrame"] [node name="RiverLip" type="MeshInstance3D" parent="RockFrame"]
position = Vector3(0, 7.05, 0.2) position = Vector3(0, 5.42, -0.2)
mesh = SubResource("Mesh_lip") mesh = SubResource("Mesh_lip")
[node name="LipFoam" type="MeshInstance3D" parent="RockFrame"]
position = Vector3(0, 5.74, -0.85)
cast_shadow = 0
mesh = SubResource("Mesh_lip_foam")
[node name="FallingWater" type="MeshInstance3D" parent="."] [node name="FallingWater" type="MeshInstance3D" parent="."]
position = Vector3(0, 0, 1.05) position = Vector3(0, 0, 1.1)
cast_shadow = 0 cast_shadow = 0
mesh = SubResource("Mesh_waterfall_body") mesh = SubResource("Mesh_waterfall_body")
+10 -7
View File
@@ -1,4 +1,6 @@
[gd_scene load_steps=4 format=3] [gd_scene load_steps=5 format=3]
[ext_resource type="Texture2D" path="res://world/jajce/materials/mist_soft.svg" id="1_mist_texture"]
[sub_resource type="ParticleProcessMaterial" id="ParticleProcessMaterial_mist"] [sub_resource type="ParticleProcessMaterial" id="ParticleProcessMaterial_mist"]
lifetime_randomness = 0.35 lifetime_randomness = 0.35
@@ -7,26 +9,27 @@ gravity = Vector3(0, -0.35, 0)
initial_velocity_min = 0.25 initial_velocity_min = 0.25
initial_velocity_max = 1.2 initial_velocity_max = 1.2
scale_min = 0.08 scale_min = 0.08
scale_max = 2.0 scale_max = 1.35
color = Color(0.82, 0.94, 0.96, 0.35) color = Color(0.82, 0.94, 0.96, 0.22)
emission_shape = 1 emission_shape = 1
emission_box_extents = Vector3(5.8, 1.5, 1.35) emission_box_extents = Vector3(5.8, 1.5, 1.35)
[sub_resource type="StandardMaterial3D" id="Material_mist"] [sub_resource type="StandardMaterial3D" id="Material_mist"]
albedo_color = Color(0.82, 0.94, 0.96, 0.28) albedo_color = Color(0.82, 0.94, 0.96, 0.3)
transparency = 1 transparency = 1
shading_mode = 0 shading_mode = 0
albedo_texture = ExtResource("1_mist_texture")
billboard_mode = 1 billboard_mode = 1
cull_mode = 0 cull_mode = 0
[sub_resource type="QuadMesh" id="Mesh_mist"] [sub_resource type="QuadMesh" id="Mesh_mist"]
material = SubResource("Material_mist") material = SubResource("Material_mist")
size = Vector2(1.8, 1.8) size = Vector2(1.45, 1.45)
[node name="WaterfallMist" type="GPUParticles3D"] [node name="WaterfallMist" type="GPUParticles3D"]
position = Vector3(25, 2.05, -24) position = Vector3(25, 0.65, -21.5)
emitting = true emitting = true
amount = 96 amount = 56
lifetime = 3.0 lifetime = 3.0
one_shot = false one_shot = false
preprocess = 1.5 preprocess = 1.5
+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<defs>
<radialGradient id="mist" cx="50%" cy="48%" r="50%">
<stop offset="0" stop-color="#dff4ef" stop-opacity="0.82"/>
<stop offset="0.42" stop-color="#c8e7e3" stop-opacity="0.48"/>
<stop offset="0.76" stop-color="#b8d9d7" stop-opacity="0.14"/>
<stop offset="1" stop-color="#b8d9d7" stop-opacity="0"/>
</radialGradient>
</defs>
<circle cx="32" cy="32" r="31" fill="url(#mist)"/>
</svg>

After

Width:  |  Height:  |  Size: 509 B

@@ -0,0 +1,43 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://c0ie27xnkmfd8"
path="res://.godot/imported/mist_soft.svg-8d0454048e96ea4cac3f1157714c34e4.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://world/jajce/materials/mist_soft.svg"
dest_files=["res://.godot/imported/mist_soft.svg-8d0454048e96ea4cac3f1157714c34e4.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false