M0.5: detailed scenery, mountain/sea-cliff boundary, Godot 4.7
Scenery (all procedural, seeded, no imported assets): - Terrain shader: grass/dry grass/dirt/sand/rock strata/snow + detail normals - 5 tree types, faceted mossy rocks, cliff outcrops, sea stacks, bushes, logs and stumps, batched as MultiMesh per 48 m cell - Wind-animated grass and flowers streamed in tiles around the player - Lake (depth-shaded water with shore foam), ocean to the horizon, cloud sky shader, volumetric fog, glow, softer shadows - Swimming: float at the surface, swim-jump to climb out World bounds: - Steep mountain rim with an invisible wall partway up; one side opens to a sheer sea cliff with the wall 1 m inside the edge; whole chunk is an island so the mesh edge is never visible - Respawn safety net if the player leaves the walkable area or the map - Fix: the cliff edge now tracks the wall in the headlands (it used to drop away inside the wall, leaving a pit with lake water hanging over it) Tests: tests/test_boundary.gd (24-direction escape attempt) and tests/test_terrain_edges.gd (no ground below waterline near the wall). Upgrades the project to Godot 4.7 (project.godot rewritten by the 4.7 importer, .uid files added) and documents it in the setup guide.
This commit is contained in:
@@ -7,13 +7,20 @@ actually run and see. Timelines assume solo dev + heavy AI assistance, steady
|
|||||||
## M0 — World skeleton (single-player) · ~2–4 weeks
|
## M0 — World skeleton (single-player) · ~2–4 weeks
|
||||||
**Goal:** walk around a lit, atmospheric terrain. Prove the look.
|
**Goal:** walk around a lit, atmospheric terrain. Prove the look.
|
||||||
- [x] Project scaffold, git repo, docs
|
- [x] Project scaffold, git repo, docs
|
||||||
- [ ] Godot installed, project opens
|
- [x] Godot installed, project opens
|
||||||
- [ ] First-person / third-person character controller (move, jump, mouse-look)
|
- [x] First-person / third-person character controller (move, jump, mouse-look)
|
||||||
- [ ] One flat/heightmap terrain chunk with a material
|
- [x] One flat/heightmap terrain chunk with a material
|
||||||
- [ ] Atmospheric lighting: directional sun, sky, volumetric fog, tonemapping
|
- [x] Atmospheric lighting: directional sun, sky, volumetric fog, tonemapping
|
||||||
- [ ] Place one low-poly tree + rock to set the mood
|
- [x] Place one low-poly tree + rock to set the mood
|
||||||
- **Done when:** you load the game and walk around a Valheim-ish lit field.
|
- **Done when:** you load the game and walk around a Valheim-ish lit field.
|
||||||
|
|
||||||
|
### M0.5 — Scenery & world bounds (done)
|
||||||
|
- [x] Mountain-rim boundary (steep cliffs + invisible wall + respawn safety net),
|
||||||
|
covered by `tests/test_boundary.gd`
|
||||||
|
- [x] Procedural terrain shader (grass/dirt/sand/rock strata/snow), lake + water shader
|
||||||
|
- [x] 5 procedural tree types, faceted mossy rocks, cliff outcrops, bushes, logs, stumps
|
||||||
|
- [x] Streamed wind-animated grass + flowers; cloud sky; swimming
|
||||||
|
|
||||||
## M1 — Survival loop (single-player) · ~3–5 weeks
|
## M1 — Survival loop (single-player) · ~3–5 weeks
|
||||||
**Goal:** the core game verbs.
|
**Goal:** the core game verbs.
|
||||||
- [ ] Resource nodes: chop tree → wood, mine rock → stone
|
- [ ] Resource nodes: chop tree → wood, mine rock → stone
|
||||||
|
|||||||
@@ -7,7 +7,19 @@ download — the M0 world is generated in code.
|
|||||||
## 1. Install Godot 4.3+
|
## 1. Install Godot 4.3+
|
||||||
- **macOS / Windows / Linux:** download the *standard* build (NOT the .NET/C#
|
- **macOS / Windows / Linux:** download the *standard* build (NOT the .NET/C#
|
||||||
build — this project is pure GDScript) from https://godotengine.org/download
|
build — this project is pure GDScript) from https://godotengine.org/download
|
||||||
- Any 4.3.x or newer 4.x works. The project was built on `4.3.stable`.
|
- Use **4.7.x or newer 4.x**. The project was upgraded to `4.7.2.stable` (an
|
||||||
|
older editor will warn that the project is from a newer version).
|
||||||
|
- macOS shortcut: `brew install --cask godot`. If Homebrew can't unpack it,
|
||||||
|
download the zip from the GitHub release page and move `Godot.app` to
|
||||||
|
`/Applications`.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
```bash
|
||||||
|
# Boundary: tries to walk/jump out of the valley in 24 directions
|
||||||
|
godot --headless --path . --fixed-fps 30 --script res://tests/test_boundary.gd
|
||||||
|
# Terrain: no pits/hanging water just inside the boundary wall
|
||||||
|
godot --headless --path . --script res://tests/test_terrain_edges.gd
|
||||||
|
```
|
||||||
|
|
||||||
## 2. Get the project onto the machine
|
## 2. Get the project onto the machine
|
||||||
You have the folder (from the zip, or a git remote). Then:
|
You have the folder (from the zip, or a git remote). Then:
|
||||||
@@ -41,8 +53,8 @@ WASD move · Mouse look · Shift sprint · Space jump · ESC frees the cursor.
|
|||||||
first open. Don't copy it between machines.
|
first open. Don't copy it between machines.
|
||||||
- If scripts look like they "didn't load", let the first editor open finish its
|
- If scripts look like they "didn't load", let the first editor open finish its
|
||||||
import pass, then press play.
|
import pass, then press play.
|
||||||
- Where we are: **M0 complete** (walkable lit world). Next is **M1** (gather →
|
- Where we are: **M0 + M0.5 complete** (detailed, bounded island valley). Next
|
||||||
craft → build). See `docs/ROADMAP.md`.
|
is **M1** (gather → craft → build). See `docs/ROADMAP.md`.
|
||||||
|
|
||||||
## Recommended: push to a git remote for cross-machine work
|
## Recommended: push to a git remote for cross-machine work
|
||||||
A zip is a one-time snapshot. For going back and forth between machines, create a
|
A zip is a one-time snapshot. For going back and forth between machines, create a
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
; Engine configuration file.
|
; Engine configuration file.
|
||||||
; Godot 4.3+ project. Open by importing this folder in the Godot editor,
|
; It's best edited using the editor UI and not directly,
|
||||||
; or run: godot --path .
|
; since the parameters that go here are not all obvious.
|
||||||
|
;
|
||||||
|
; Format:
|
||||||
|
; [section] ; section goes between []
|
||||||
|
; param=value ; assign values to parameters
|
||||||
|
|
||||||
config_version=5
|
config_version=5
|
||||||
|
|
||||||
|
[animation]
|
||||||
|
|
||||||
|
compatibility/default_parent_skeleton_in_mesh_instance_3d=true
|
||||||
|
|
||||||
[application]
|
[application]
|
||||||
|
|
||||||
config/name="Valheimlike"
|
config/name="Valheimlike"
|
||||||
config/description="Low-poly survival game — Valheim look, Rust depth, self-hosted, moddable."
|
config/description="Low-poly survival game — Valheim look, Rust depth, self-hosted, moddable."
|
||||||
run/main_scene="res://scenes/world.tscn"
|
run/main_scene="res://scenes/world.tscn"
|
||||||
config/features=PackedStringArray("4.3", "Forward Plus")
|
config/features=PackedStringArray("4.7", "Forward Plus")
|
||||||
config/icon="res://icon.svg"
|
config/icon="res://icon.svg"
|
||||||
|
|
||||||
[display]
|
[display]
|
||||||
@@ -22,23 +30,28 @@ window/stretch/mode="canvas_items"
|
|||||||
|
|
||||||
move_forward={
|
move_forward={
|
||||||
"deadzone": 0.5,
|
"deadzone": 0.5,
|
||||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":87,"key_label":0,"unicode":119,"location":0,"echo":false,"script":null)]
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":87,"key_label":0,"unicode":119,"location":0,"echo":false,"script":null)
|
||||||
|
]
|
||||||
}
|
}
|
||||||
move_back={
|
move_back={
|
||||||
"deadzone": 0.5,
|
"deadzone": 0.5,
|
||||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":83,"key_label":0,"unicode":115,"location":0,"echo":false,"script":null)]
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":83,"key_label":0,"unicode":115,"location":0,"echo":false,"script":null)
|
||||||
|
]
|
||||||
}
|
}
|
||||||
move_left={
|
move_left={
|
||||||
"deadzone": 0.5,
|
"deadzone": 0.5,
|
||||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":65,"key_label":0,"unicode":97,"location":0,"echo":false,"script":null)]
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":65,"key_label":0,"unicode":97,"location":0,"echo":false,"script":null)
|
||||||
|
]
|
||||||
}
|
}
|
||||||
move_right={
|
move_right={
|
||||||
"deadzone": 0.5,
|
"deadzone": 0.5,
|
||||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":68,"key_label":0,"unicode":100,"location":0,"echo":false,"script":null)]
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":68,"key_label":0,"unicode":100,"location":0,"echo":false,"script":null)
|
||||||
|
]
|
||||||
}
|
}
|
||||||
jump={
|
jump={
|
||||||
"deadzone": 0.5,
|
"deadzone": 0.5,
|
||||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":32,"key_label":0,"unicode":32,"location":0,"echo":false,"script":null)]
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":32,"key_label":0,"unicode":32,"location":0,"echo":false,"script":null)
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
[physics]
|
[physics]
|
||||||
@@ -47,5 +60,7 @@ common/physics_ticks_per_second=30
|
|||||||
|
|
||||||
[rendering]
|
[rendering]
|
||||||
|
|
||||||
renderer/rendering_method="forward_plus"
|
lights_and_shadows/directional_shadow/size=8192
|
||||||
|
lights_and_shadows/directional_shadow/soft_shadow_filter_quality=3
|
||||||
|
anti_aliasing/quality/msaa_3d=2
|
||||||
environment/defaults/default_environment="res://scenes/default_env.tres"
|
environment/defaults/default_environment="res://scenes/default_env.tres"
|
||||||
|
|||||||
@@ -1,11 +1,24 @@
|
|||||||
[gd_resource type="Environment" load_steps=4 format=3]
|
[gd_resource type="Environment" load_steps=6 format=3]
|
||||||
|
|
||||||
[sub_resource type="ProceduralSkyMaterial" id="SkyMat_1"]
|
[ext_resource type="Shader" path="res://shaders/sky.gdshader" id="1_sky"]
|
||||||
sky_top_color = Color(0.25, 0.45, 0.72, 1)
|
|
||||||
sky_horizon_color = Color(0.68, 0.76, 0.82, 1)
|
[sub_resource type="FastNoiseLite" id="CloudNoise_1"]
|
||||||
ground_bottom_color = Color(0.18, 0.2, 0.18, 1)
|
noise_type = 1
|
||||||
ground_horizon_color = Color(0.58, 0.62, 0.6, 1)
|
seed = 7
|
||||||
sun_angle_max = 40.0
|
frequency = 0.006
|
||||||
|
fractal_octaves = 5
|
||||||
|
|
||||||
|
[sub_resource type="NoiseTexture2D" id="CloudTex_1"]
|
||||||
|
width = 1024
|
||||||
|
height = 1024
|
||||||
|
seamless = true
|
||||||
|
noise = SubResource("CloudNoise_1")
|
||||||
|
|
||||||
|
[sub_resource type="ShaderMaterial" id="SkyMat_1"]
|
||||||
|
shader = ExtResource("1_sky")
|
||||||
|
shader_parameter/cloud_tex = SubResource("CloudTex_1")
|
||||||
|
shader_parameter/cloud_cover = 0.5
|
||||||
|
shader_parameter/energy = 1.0
|
||||||
|
|
||||||
[sub_resource type="Sky" id="Sky_1"]
|
[sub_resource type="Sky" id="Sky_1"]
|
||||||
sky_material = SubResource("SkyMat_1")
|
sky_material = SubResource("SkyMat_1")
|
||||||
@@ -14,21 +27,35 @@ sky_material = SubResource("SkyMat_1")
|
|||||||
background_mode = 2
|
background_mode = 2
|
||||||
sky = SubResource("Sky_1")
|
sky = SubResource("Sky_1")
|
||||||
ambient_light_source = 3
|
ambient_light_source = 3
|
||||||
ambient_light_color = Color(0.62, 0.68, 0.76, 1)
|
ambient_light_energy = 1.0
|
||||||
ambient_light_energy = 0.6
|
reflected_light_source = 2
|
||||||
tonemap_mode = 3
|
tonemap_mode = 3
|
||||||
tonemap_white = 5.0
|
tonemap_white = 6.0
|
||||||
ssao_enabled = true
|
ssao_enabled = true
|
||||||
ssao_radius = 2.0
|
ssao_radius = 1.5
|
||||||
ssao_intensity = 1.0
|
ssao_intensity = 1.6
|
||||||
|
ssao_power = 1.5
|
||||||
|
ssil_enabled = true
|
||||||
|
glow_enabled = true
|
||||||
|
glow_intensity = 0.5
|
||||||
|
glow_bloom = 0.05
|
||||||
|
glow_hdr_threshold = 1.2
|
||||||
fog_enabled = true
|
fog_enabled = true
|
||||||
fog_mode = 0
|
fog_mode = 0
|
||||||
fog_light_color = Color(0.74, 0.8, 0.86, 1)
|
fog_light_color = Color(0.70, 0.77, 0.85, 1)
|
||||||
fog_light_energy = 1.0
|
fog_light_energy = 1.0
|
||||||
fog_density = 0.004
|
fog_sun_scatter = 0.25
|
||||||
fog_sky_affect = 0.0
|
fog_density = 0.0035
|
||||||
fog_aerial_perspective = 0.2
|
fog_aerial_perspective = 0.55
|
||||||
|
fog_sky_affect = 0.25
|
||||||
|
volumetric_fog_enabled = true
|
||||||
|
volumetric_fog_density = 0.0045
|
||||||
|
volumetric_fog_albedo = Color(0.9, 0.92, 0.95, 1)
|
||||||
|
volumetric_fog_anisotropy = 0.45
|
||||||
|
volumetric_fog_length = 96.0
|
||||||
|
volumetric_fog_sky_affect = 0.0
|
||||||
|
volumetric_fog_ambient_inject = 0.3
|
||||||
adjustment_enabled = true
|
adjustment_enabled = true
|
||||||
adjustment_brightness = 1.05
|
adjustment_brightness = 1.0
|
||||||
adjustment_contrast = 1.12
|
adjustment_contrast = 1.08
|
||||||
adjustment_saturation = 1.25
|
adjustment_saturation = 1.15
|
||||||
|
|||||||
@@ -20,10 +20,12 @@ environment = ExtResource("4_env")
|
|||||||
[node name="Sun" type="DirectionalLight3D" parent="."]
|
[node name="Sun" type="DirectionalLight3D" parent="."]
|
||||||
transform = Transform3D(0.819, -0.401, 0.41, 0, 0.715, 0.699, -0.574, -0.573, 0.585, 0, 40, 0)
|
transform = Transform3D(0.819, -0.401, 0.41, 0, 0.715, 0.699, -0.574, -0.573, 0.585, 0, 40, 0)
|
||||||
light_color = Color(1, 0.95, 0.83, 1)
|
light_color = Color(1, 0.95, 0.83, 1)
|
||||||
light_energy = 1.7
|
light_energy = 1.8
|
||||||
shadow_enabled = true
|
shadow_enabled = true
|
||||||
|
shadow_blur = 1.5
|
||||||
directional_shadow_mode = 2
|
directional_shadow_mode = 2
|
||||||
directional_shadow_max_distance = 200.0
|
directional_shadow_blend_splits = true
|
||||||
|
directional_shadow_max_distance = 160.0
|
||||||
|
|
||||||
[node name="Terrain" type="Node3D" parent="."]
|
[node name="Terrain" type="Node3D" parent="."]
|
||||||
script = ExtResource("1_terrain")
|
script = ExtResource("1_terrain")
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
extends CharacterBody3D
|
extends CharacterBody3D
|
||||||
## First-person player controller — M0.
|
## First-person player controller.
|
||||||
##
|
##
|
||||||
## Move (WASD), look (mouse), jump (space). Gravity + ground collision via
|
## Move (WASD), look (mouse), jump (space), sprint (shift). Gravity + ground
|
||||||
## CharacterBody3D. Deliberately minimal: no stamina/health yet (M1), no network
|
## collision via CharacterBody3D. In deep water the player floats with their
|
||||||
## ownership yet (M2). Press ESC to free the mouse, click to recapture.
|
## head above the surface and moves at swimming speed. Deliberately minimal: no
|
||||||
|
## stamina/health yet (M1), no network ownership yet (M2). Press ESC to free the
|
||||||
|
## mouse, click to recapture.
|
||||||
|
|
||||||
@export var speed: float = 5.0
|
@export var speed: float = 5.0
|
||||||
@export var sprint_speed: float = 8.0
|
@export var sprint_speed: float = 8.0
|
||||||
|
@export var swim_speed: float = 2.6
|
||||||
@export var jump_velocity: float = 5.5
|
@export var jump_velocity: float = 5.5
|
||||||
@export var mouse_sensitivity: float = 0.0025
|
@export var mouse_sensitivity: float = 0.0025
|
||||||
|
## Set by world.gd from the terrain. Feet this far below the surface = swimming.
|
||||||
|
@export var water_level: float = -INF
|
||||||
|
const FLOAT_DEPTH := 1.3
|
||||||
|
|
||||||
@onready var camera: Camera3D = $Camera3D
|
@onready var camera: Camera3D = $Camera3D
|
||||||
|
|
||||||
@@ -28,16 +34,27 @@ func _unhandled_input(event: InputEvent) -> void:
|
|||||||
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
|
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
|
||||||
|
|
||||||
func _physics_process(delta: float) -> void:
|
func _physics_process(delta: float) -> void:
|
||||||
if not is_on_floor():
|
var float_y := water_level - FLOAT_DEPTH
|
||||||
|
var swimming := global_position.y < float_y + 0.05 and not is_on_floor()
|
||||||
|
|
||||||
|
if global_position.y < float_y:
|
||||||
|
# Buoyancy: ease back up to floating height.
|
||||||
|
velocity.y = move_toward(velocity.y, clampf((float_y - global_position.y) * 4.0, -3.0, 3.0), 20.0 * delta)
|
||||||
|
elif not is_on_floor():
|
||||||
velocity.y -= _gravity * delta
|
velocity.y -= _gravity * delta
|
||||||
|
|
||||||
if Input.is_action_just_pressed("jump") and is_on_floor():
|
if Input.is_action_just_pressed("jump") and is_on_floor():
|
||||||
velocity.y = jump_velocity
|
velocity.y = jump_velocity
|
||||||
|
elif Input.is_action_just_pressed("jump") and global_position.y > float_y - 0.4 and global_position.y < float_y + 0.3:
|
||||||
|
# Swim-jump: kick up out of the water to climb onto a bank.
|
||||||
|
velocity.y = jump_velocity
|
||||||
|
|
||||||
var input_dir := Input.get_vector("move_left", "move_right", "move_forward", "move_back")
|
var input_dir := Input.get_vector("move_left", "move_right", "move_forward", "move_back")
|
||||||
var direction := (transform.basis * Vector3(input_dir.x, 0, input_dir.y)).normalized()
|
var direction := (transform.basis * Vector3(input_dir.x, 0, input_dir.y)).normalized()
|
||||||
|
|
||||||
var current_speed := sprint_speed if Input.is_key_pressed(KEY_SHIFT) else speed
|
var current_speed := sprint_speed if Input.is_key_pressed(KEY_SHIFT) else speed
|
||||||
|
if swimming:
|
||||||
|
current_speed = swim_speed
|
||||||
if direction:
|
if direction:
|
||||||
velocity.x = direction.x * current_speed
|
velocity.x = direction.x * current_speed
|
||||||
velocity.z = direction.z * current_speed
|
velocity.z = direction.z * current_speed
|
||||||
|
|||||||
1
scripts/player/player.gd.uid
Normal file
1
scripts/player/player.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://dbsqgv3wwo41p
|
||||||
372
scripts/world/proc_mesh.gd
Normal file
372
scripts/world/proc_mesh.gd
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
extends RefCounted
|
||||||
|
## Procedural low-poly mesh builders: trees, rocks, bushes, logs, grass, flowers.
|
||||||
|
##
|
||||||
|
## Every builder takes a seed, so the same seed gives the same mesh on every
|
||||||
|
## machine (the determinism rule in docs/ARCHITECTURE.md §4). Meshes are
|
||||||
|
## flat-shaded and coloured per face through vertex colours (authored as sRGB).
|
||||||
|
## Callers assign materials per surface; tree dictionaries list which surface is
|
||||||
|
## which in "surfaces".
|
||||||
|
##
|
||||||
|
## Winding: Godot treats triangle (a, b, c) as front-facing when
|
||||||
|
## (b - a).cross(c - a) points AWAY from the viewer. `_tri()` orders the
|
||||||
|
## vertices from an outward hint, so builders never have to think about it.
|
||||||
|
|
||||||
|
const BARK := Color(0.31, 0.22, 0.15)
|
||||||
|
const BARK_GREY := Color(0.42, 0.38, 0.33)
|
||||||
|
const BIRCH_WHITE := Color(0.86, 0.85, 0.80)
|
||||||
|
const BIRCH_DARK := Color(0.16, 0.15, 0.14)
|
||||||
|
const WOOD := Color(0.70, 0.55, 0.36)
|
||||||
|
const SPRUCE := Color(0.13, 0.27, 0.17)
|
||||||
|
const PINE := Color(0.18, 0.31, 0.16)
|
||||||
|
const LEAF_GREEN := Color(0.27, 0.43, 0.16)
|
||||||
|
const LEAF_GOLD := Color(0.62, 0.50, 0.15)
|
||||||
|
const BIRCH_LEAF := Color(0.42, 0.54, 0.20)
|
||||||
|
const BUSH := Color(0.19, 0.34, 0.14)
|
||||||
|
const ROCK := Color(0.50, 0.49, 0.47)
|
||||||
|
const STEM := Color(0.25, 0.42, 0.15)
|
||||||
|
|
||||||
|
static var _ico_cache := {}
|
||||||
|
|
||||||
|
# --- primitives -------------------------------------------------------------
|
||||||
|
|
||||||
|
static func _begin() -> SurfaceTool:
|
||||||
|
var st := SurfaceTool.new()
|
||||||
|
st.begin(Mesh.PRIMITIVE_TRIANGLES)
|
||||||
|
st.set_smooth_group(-1) # flat shading
|
||||||
|
return st
|
||||||
|
|
||||||
|
static func _commit(st: SurfaceTool, mesh: ArrayMesh = null) -> ArrayMesh:
|
||||||
|
st.generate_normals()
|
||||||
|
return st.commit(mesh)
|
||||||
|
|
||||||
|
static func _mul(c: Color, k: float) -> Color:
|
||||||
|
return Color(c.r * k, c.g * k, c.b * k, 1.0)
|
||||||
|
|
||||||
|
static func _tri(st: SurfaceTool, a: Vector3, b: Vector3, c: Vector3, outward: Vector3, col: Color) -> void:
|
||||||
|
if (b - a).cross(c - a).dot(outward) > 0.0:
|
||||||
|
var tmp := b
|
||||||
|
b = c
|
||||||
|
c = tmp
|
||||||
|
st.set_color(col)
|
||||||
|
st.add_vertex(a)
|
||||||
|
st.set_color(col)
|
||||||
|
st.add_vertex(b)
|
||||||
|
st.set_color(col)
|
||||||
|
st.add_vertex(c)
|
||||||
|
|
||||||
|
## Tapered cylinder from `base` to `top`. `off` fixes the ring rotation so
|
||||||
|
## stacked segments line up; cap_col.a > 0 adds end caps in that colour.
|
||||||
|
static func _cylinder(st: SurfaceTool, base: Vector3, top: Vector3, r0: float, r1: float, segs: int,
|
||||||
|
col: Color, rng: RandomNumberGenerator, off: float = -1.0, cap_col: Color = Color(0, 0, 0, 0)) -> void:
|
||||||
|
var ax := (top - base).normalized()
|
||||||
|
var ref := Vector3.UP if absf(ax.y) < 0.95 else Vector3.RIGHT
|
||||||
|
var u := ax.cross(ref).normalized()
|
||||||
|
var v := ax.cross(u).normalized()
|
||||||
|
if off < 0.0:
|
||||||
|
off = rng.randf() * TAU
|
||||||
|
var ring0: Array[Vector3] = []
|
||||||
|
var ring1: Array[Vector3] = []
|
||||||
|
for i in range(segs):
|
||||||
|
var a := off + TAU * i / segs
|
||||||
|
var dir := u * cos(a) + v * sin(a)
|
||||||
|
ring0.append(base + dir * r0)
|
||||||
|
ring1.append(top + dir * r1)
|
||||||
|
for i in range(segs):
|
||||||
|
var j := (i + 1) % segs
|
||||||
|
var mid := (ring0[i] + ring0[j] + ring1[i] + ring1[j]) * 0.25
|
||||||
|
var out := mid - (base + ax * (mid - base).dot(ax))
|
||||||
|
var c := _mul(col, rng.randf_range(0.85, 1.1))
|
||||||
|
_tri(st, ring0[i], ring0[j], ring1[j], out, c)
|
||||||
|
_tri(st, ring0[i], ring1[j], ring1[i], out, c)
|
||||||
|
if cap_col.a > 0.0:
|
||||||
|
for i in range(segs):
|
||||||
|
var j := (i + 1) % segs
|
||||||
|
_tri(st, top, ring1[i], ring1[j], ax, cap_col)
|
||||||
|
_tri(st, base, ring0[i], ring0[j], -ax, cap_col)
|
||||||
|
|
||||||
|
static func icosphere(subdiv: int) -> Array:
|
||||||
|
if _ico_cache.has(subdiv):
|
||||||
|
return _ico_cache[subdiv]
|
||||||
|
var t := (1.0 + sqrt(5.0)) / 2.0
|
||||||
|
var v := [
|
||||||
|
Vector3(-1, t, 0), Vector3(1, t, 0), Vector3(-1, -t, 0), Vector3(1, -t, 0),
|
||||||
|
Vector3(0, -1, t), Vector3(0, 1, t), Vector3(0, -1, -t), Vector3(0, 1, -t),
|
||||||
|
Vector3(t, 0, -1), Vector3(t, 0, 1), Vector3(-t, 0, -1), Vector3(-t, 0, 1),
|
||||||
|
]
|
||||||
|
var faces := [
|
||||||
|
[0, 11, 5], [0, 5, 1], [0, 1, 7], [0, 7, 10], [0, 10, 11],
|
||||||
|
[1, 5, 9], [5, 11, 4], [11, 10, 2], [10, 7, 6], [7, 1, 8],
|
||||||
|
[3, 9, 4], [3, 4, 2], [3, 2, 6], [3, 6, 8], [3, 8, 9],
|
||||||
|
[4, 9, 5], [2, 4, 11], [6, 2, 10], [8, 6, 7], [9, 8, 1],
|
||||||
|
]
|
||||||
|
var tris := []
|
||||||
|
for f in faces:
|
||||||
|
tris.append([(v[f[0]] as Vector3).normalized(), (v[f[1]] as Vector3).normalized(), (v[f[2]] as Vector3).normalized()])
|
||||||
|
for _s in range(subdiv):
|
||||||
|
var nt := []
|
||||||
|
for tr in tris:
|
||||||
|
var a: Vector3 = tr[0]
|
||||||
|
var b: Vector3 = tr[1]
|
||||||
|
var c: Vector3 = tr[2]
|
||||||
|
var ab := ((a + b) * 0.5).normalized()
|
||||||
|
var bc := ((b + c) * 0.5).normalized()
|
||||||
|
var ca := ((c + a) * 0.5).normalized()
|
||||||
|
nt.append([a, ab, ca])
|
||||||
|
nt.append([b, bc, ab])
|
||||||
|
nt.append([c, ca, bc])
|
||||||
|
nt.append([ab, bc, ca])
|
||||||
|
tris = nt
|
||||||
|
_ico_cache[subdiv] = tris
|
||||||
|
return tris
|
||||||
|
|
||||||
|
## Lumpy flat-shaded sphere. Displacement is a function of position, so shared
|
||||||
|
## corners move together and the surface stays watertight.
|
||||||
|
static func _blob(st: SurfaceTool, center: Vector3, radii: Vector3, noise: FastNoiseLite, amount: float,
|
||||||
|
col: Color, rng: RandomNumberGenerator, subdiv: int = 1) -> void:
|
||||||
|
for tr in icosphere(subdiv):
|
||||||
|
var p: Array[Vector3] = []
|
||||||
|
for k in range(3):
|
||||||
|
var q: Vector3 = tr[k]
|
||||||
|
var d := 1.0 + noise.get_noise_3dv(q * 1.7 + center) * amount
|
||||||
|
p.append(center + q * radii * d)
|
||||||
|
_tri(st, p[0], p[1], p[2], (p[0] + p[1] + p[2]) / 3.0 - center, _mul(col, rng.randf_range(0.88, 1.1)))
|
||||||
|
|
||||||
|
## One drooping tier of a conifer: a star-shaped ring (alternating branch tips
|
||||||
|
## and notches) rising to an apex, with a darker underside.
|
||||||
|
static func _conifer_tier(st: SurfaceTool, center: Vector3, r: float, h: float, segs: int,
|
||||||
|
col: Color, rng: RandomNumberGenerator) -> void:
|
||||||
|
var n := segs * 2
|
||||||
|
var ring: Array[Vector3] = []
|
||||||
|
var off := rng.randf() * TAU
|
||||||
|
for i in range(n):
|
||||||
|
var a := off + TAU * i / n + rng.randf_range(-0.12, 0.12)
|
||||||
|
var tip := i % 2 == 0
|
||||||
|
var rr := r * (rng.randf_range(0.9, 1.12) if tip else rng.randf_range(0.58, 0.7))
|
||||||
|
var yy := -(r * 0.32 if tip else r * 0.12) + rng.randf_range(-0.08, 0.08)
|
||||||
|
ring.append(center + Vector3(cos(a) * rr, yy, sin(a) * rr))
|
||||||
|
var apex := center + Vector3(rng.randf_range(-0.08, 0.08), h, rng.randf_range(-0.08, 0.08))
|
||||||
|
var under := center + Vector3(0.0, h * 0.18, 0.0)
|
||||||
|
for i in range(n):
|
||||||
|
var a := ring[i]
|
||||||
|
var b := ring[(i + 1) % n]
|
||||||
|
var mid := (a + b) * 0.5 - center
|
||||||
|
var out := Vector3(mid.x, 0.0, mid.z).normalized() + Vector3(0.0, 0.8, 0.0)
|
||||||
|
_tri(st, apex, a, b, out, _mul(col, rng.randf_range(0.85, 1.12)))
|
||||||
|
_tri(st, under, a, b, Vector3(mid.x * 0.3, -1.0, mid.z * 0.3), _mul(col, 0.55))
|
||||||
|
|
||||||
|
# --- trees ------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Spruce (bushy, tiers from near the ground) or pine (tall, tiers only up top).
|
||||||
|
static func conifer(seed: int, tall: bool) -> Dictionary:
|
||||||
|
var rng := RandomNumberGenerator.new()
|
||||||
|
rng.seed = seed
|
||||||
|
var bark := _begin()
|
||||||
|
var leaf := _begin()
|
||||||
|
var trunk_h := rng.randf_range(8.5, 10.5) if tall else rng.randf_range(5.5, 7.0)
|
||||||
|
var trunk_r := 0.26 if tall else 0.3
|
||||||
|
var lean := Vector3(rng.randf_range(-0.15, 0.15), 0.0, rng.randf_range(-0.15, 0.15))
|
||||||
|
_cylinder(bark, Vector3(0, -0.5, 0), Vector3(0, trunk_h, 0) + lean, trunk_r, 0.05, 7, BARK, rng)
|
||||||
|
|
||||||
|
var tiers := rng.randi_range(4, 5) if tall else rng.randi_range(5, 7)
|
||||||
|
var y0 := trunk_h * 0.55 if tall else 1.3
|
||||||
|
var y1 := trunk_h - 0.6
|
||||||
|
var r_bottom := 1.7 if tall else 2.4
|
||||||
|
var col := _mul(PINE if tall else SPRUCE, rng.randf_range(0.9, 1.1))
|
||||||
|
for i in range(tiers):
|
||||||
|
var f := float(i) / float(tiers - 1)
|
||||||
|
var y := lerpf(y0, y1, f)
|
||||||
|
var r := lerpf(r_bottom, 0.55, pow(f, 0.9)) * rng.randf_range(0.9, 1.1)
|
||||||
|
_conifer_tier(leaf, Vector3(0, y, 0) + lean * (y / trunk_h), r, lerpf(1.9, 1.3, f), 9, col, rng)
|
||||||
|
_conifer_tier(leaf, Vector3(0, y1 + 0.9, 0) + lean, 0.35, 1.1, 5, col, rng)
|
||||||
|
|
||||||
|
var mesh := _commit(bark)
|
||||||
|
_commit(leaf, mesh)
|
||||||
|
return {"mesh": mesh, "surfaces": ["bark", "leaf"], "trunk_r": trunk_r, "trunk_h": trunk_h}
|
||||||
|
|
||||||
|
## Broadleaf (oak-ish): short thick trunk, a few branches, a crown of blobs.
|
||||||
|
static func broadleaf(seed: int, leaf_col: Color) -> Dictionary:
|
||||||
|
var rng := RandomNumberGenerator.new()
|
||||||
|
rng.seed = seed
|
||||||
|
var noise := FastNoiseLite.new()
|
||||||
|
noise.seed = seed
|
||||||
|
noise.frequency = 1.3
|
||||||
|
var bark := _begin()
|
||||||
|
var leaf := _begin()
|
||||||
|
var trunk_h := rng.randf_range(2.6, 3.6)
|
||||||
|
var top := Vector3(rng.randf_range(-0.3, 0.3), trunk_h, rng.randf_range(-0.3, 0.3))
|
||||||
|
_cylinder(bark, Vector3(0, -0.4, 0), top, 0.36, 0.2, 7, BARK, rng)
|
||||||
|
var k := rng.randi_range(3, 4)
|
||||||
|
for i in range(k):
|
||||||
|
var a := TAU * i / k + rng.randf_range(-0.4, 0.4)
|
||||||
|
var start := top * rng.randf_range(0.75, 1.0)
|
||||||
|
var end := top + Vector3(cos(a) * rng.randf_range(1.1, 1.8), rng.randf_range(0.6, 1.4), sin(a) * rng.randf_range(1.1, 1.8))
|
||||||
|
_cylinder(bark, start, end, 0.15, 0.06, 5, BARK, rng)
|
||||||
|
|
||||||
|
var crown := top + Vector3(0, 1.4, 0)
|
||||||
|
var col := _mul(leaf_col, rng.randf_range(0.92, 1.08))
|
||||||
|
var blobs := rng.randi_range(6, 9)
|
||||||
|
for i in range(blobs):
|
||||||
|
var c := crown + Vector3(rng.randf_range(-1.7, 1.7), rng.randf_range(-0.6, 1.2), rng.randf_range(-1.7, 1.7))
|
||||||
|
if i == 0:
|
||||||
|
c = crown + Vector3(0, 0.8, 0)
|
||||||
|
var r := rng.randf_range(1.0, 1.6)
|
||||||
|
_blob(leaf, c, Vector3(r, r * 0.82, r), noise, 0.28, _mul(col, rng.randf_range(0.85, 1.15)), rng)
|
||||||
|
|
||||||
|
var mesh := _commit(bark)
|
||||||
|
_commit(leaf, mesh)
|
||||||
|
return {"mesh": mesh, "surfaces": ["bark", "leaf"], "trunk_r": 0.36, "trunk_h": trunk_h}
|
||||||
|
|
||||||
|
## Birch: slim white trunk with dark bands, light airy crown.
|
||||||
|
static func birch(seed: int) -> Dictionary:
|
||||||
|
var rng := RandomNumberGenerator.new()
|
||||||
|
rng.seed = seed
|
||||||
|
var noise := FastNoiseLite.new()
|
||||||
|
noise.seed = seed
|
||||||
|
noise.frequency = 1.5
|
||||||
|
var bark := _begin()
|
||||||
|
var leaf := _begin()
|
||||||
|
var trunk_h := rng.randf_range(6.0, 8.0)
|
||||||
|
var lean := Vector3(rng.randf_range(-0.4, 0.4), 0.0, rng.randf_range(-0.4, 0.4))
|
||||||
|
var off := rng.randf() * TAU
|
||||||
|
var y := -0.4
|
||||||
|
while y < trunk_h:
|
||||||
|
var dark := rng.randf() < 0.3
|
||||||
|
var seg_h := rng.randf_range(0.12, 0.22) if dark else rng.randf_range(0.5, 1.0)
|
||||||
|
var y2 := minf(y + seg_h, trunk_h)
|
||||||
|
var r0 := lerpf(0.18, 0.05, maxf(y, 0.0) / trunk_h)
|
||||||
|
var r1 := lerpf(0.18, 0.05, y2 / trunk_h)
|
||||||
|
var col := BIRCH_DARK if dark else _mul(BIRCH_WHITE, rng.randf_range(0.92, 1.03))
|
||||||
|
_cylinder(bark, Vector3(0, y, 0) + lean * (maxf(y, 0.0) / trunk_h), Vector3(0, y2, 0) + lean * (y2 / trunk_h),
|
||||||
|
r0, r1, 6, col, rng, off)
|
||||||
|
y = y2
|
||||||
|
var col_leaf := _mul(BIRCH_LEAF, rng.randf_range(0.92, 1.08))
|
||||||
|
for i in range(rng.randi_range(5, 7)):
|
||||||
|
var cy := rng.randf_range(trunk_h * 0.5, trunk_h + 0.6)
|
||||||
|
var c := Vector3(rng.randf_range(-0.9, 0.9), cy, rng.randf_range(-0.9, 0.9)) + lean * (cy / trunk_h)
|
||||||
|
var r := rng.randf_range(0.7, 1.1)
|
||||||
|
_blob(leaf, c, Vector3(r, r * 1.35, r), noise, 0.3, _mul(col_leaf, rng.randf_range(0.85, 1.15)), rng)
|
||||||
|
var mesh := _commit(bark)
|
||||||
|
_commit(leaf, mesh)
|
||||||
|
return {"mesh": mesh, "surfaces": ["bark", "leaf"], "trunk_r": 0.2, "trunk_h": trunk_h}
|
||||||
|
|
||||||
|
## Dead tree: bare grey trunk with forked branches.
|
||||||
|
static func dead_tree(seed: int) -> Dictionary:
|
||||||
|
var rng := RandomNumberGenerator.new()
|
||||||
|
rng.seed = seed
|
||||||
|
var bark := _begin()
|
||||||
|
var trunk_h := rng.randf_range(4.0, 6.0)
|
||||||
|
var top := Vector3(rng.randf_range(-0.3, 0.3), trunk_h, rng.randf_range(-0.3, 0.3))
|
||||||
|
_cylinder(bark, Vector3(0, -0.4, 0), top, 0.28, 0.07, 6, BARK_GREY, rng)
|
||||||
|
for i in range(rng.randi_range(4, 6)):
|
||||||
|
var a := rng.randf() * TAU
|
||||||
|
var start := top * rng.randf_range(0.45, 0.95)
|
||||||
|
var end := start + Vector3(cos(a) * rng.randf_range(0.8, 1.6), rng.randf_range(0.5, 1.5), sin(a) * rng.randf_range(0.8, 1.6))
|
||||||
|
_cylinder(bark, start, end, 0.09, 0.03, 4, BARK_GREY, rng)
|
||||||
|
var a2 := a + rng.randf_range(-0.8, 0.8)
|
||||||
|
_cylinder(bark, end, end + Vector3(cos(a2) * 0.6, rng.randf_range(0.3, 0.8), sin(a2) * 0.6), 0.04, 0.015, 4, BARK_GREY, rng)
|
||||||
|
return {"mesh": _commit(bark), "surfaces": ["bark"], "trunk_r": 0.28, "trunk_h": trunk_h}
|
||||||
|
|
||||||
|
# --- small props ------------------------------------------------------------
|
||||||
|
|
||||||
|
static func bush(seed: int) -> Dictionary:
|
||||||
|
var rng := RandomNumberGenerator.new()
|
||||||
|
rng.seed = seed
|
||||||
|
var noise := FastNoiseLite.new()
|
||||||
|
noise.seed = seed
|
||||||
|
noise.frequency = 2.0
|
||||||
|
var leaf := _begin()
|
||||||
|
var col := _mul(BUSH, rng.randf_range(0.9, 1.15))
|
||||||
|
for i in range(rng.randi_range(3, 5)):
|
||||||
|
var r := rng.randf_range(0.45, 0.8)
|
||||||
|
var c := Vector3(rng.randf_range(-0.55, 0.55), rng.randf_range(0.25, 0.6), rng.randf_range(-0.55, 0.55))
|
||||||
|
_blob(leaf, c, Vector3(r, r * 0.8, r), noise, 0.3, _mul(col, rng.randf_range(0.85, 1.15)), rng)
|
||||||
|
return {"mesh": _commit(leaf), "surfaces": ["leaf"]}
|
||||||
|
|
||||||
|
## Faceted rock. `tall` gives slab/pillar shapes for cliff outcrops.
|
||||||
|
static func rock(seed: int, tall: bool) -> ArrayMesh:
|
||||||
|
var rng := RandomNumberGenerator.new()
|
||||||
|
rng.seed = seed
|
||||||
|
var noise := FastNoiseLite.new()
|
||||||
|
noise.seed = seed
|
||||||
|
noise.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH
|
||||||
|
noise.frequency = 0.9
|
||||||
|
var squash := Vector3(rng.randf_range(0.9, 1.4), rng.randf_range(0.55, 0.85), rng.randf_range(0.8, 1.2))
|
||||||
|
if tall:
|
||||||
|
squash = Vector3(rng.randf_range(0.7, 1.0), rng.randf_range(1.2, 1.6), rng.randf_range(0.6, 0.9))
|
||||||
|
var st := _begin()
|
||||||
|
var base_col := _mul(ROCK, rng.randf_range(0.9, 1.1))
|
||||||
|
for tr in icosphere(1):
|
||||||
|
var p: Array[Vector3] = []
|
||||||
|
for k in range(3):
|
||||||
|
var q: Vector3 = tr[k]
|
||||||
|
q = q * (1.0 + noise.get_noise_3dv(q) * 0.38) * squash
|
||||||
|
q.y = maxf(q.y, -0.35 * squash.y) # flat-ish base so it sits on the ground
|
||||||
|
p.append(q)
|
||||||
|
_tri(st, p[0], p[1], p[2], p[0] + p[1] + p[2], _mul(base_col, rng.randf_range(0.82, 1.12)))
|
||||||
|
return _commit(st)
|
||||||
|
|
||||||
|
static func log_mesh(seed: int, length: float, radius: float) -> ArrayMesh:
|
||||||
|
var rng := RandomNumberGenerator.new()
|
||||||
|
rng.seed = seed
|
||||||
|
var st := _begin()
|
||||||
|
_cylinder(st, Vector3(-length * 0.5, 0, 0), Vector3(length * 0.5, 0, 0), radius, radius * 0.85, 8, BARK, rng, -1.0, WOOD)
|
||||||
|
return _commit(st)
|
||||||
|
|
||||||
|
static func stump_mesh(seed: int, height: float, radius: float) -> ArrayMesh:
|
||||||
|
var rng := RandomNumberGenerator.new()
|
||||||
|
rng.seed = seed
|
||||||
|
var st := _begin()
|
||||||
|
_cylinder(st, Vector3(0, -0.3, 0), Vector3(0, height, 0), radius * 1.15, radius, 8, BARK, rng, -1.0, WOOD)
|
||||||
|
return _commit(st)
|
||||||
|
|
||||||
|
## A clump of tapered blades. UV.y = 0 at the root, 1 at the tip (drives wind).
|
||||||
|
static func grass_clump(seed: int, blades: int = 10) -> ArrayMesh:
|
||||||
|
var rng := RandomNumberGenerator.new()
|
||||||
|
rng.seed = seed
|
||||||
|
var st := SurfaceTool.new()
|
||||||
|
st.begin(Mesh.PRIMITIVE_TRIANGLES)
|
||||||
|
for i in range(blades):
|
||||||
|
var a := rng.randf() * TAU
|
||||||
|
var rr := sqrt(rng.randf()) * 0.3
|
||||||
|
var pos := Vector3(cos(a) * rr, -0.05, sin(a) * rr)
|
||||||
|
var face := rng.randf() * TAU
|
||||||
|
var side := Vector3(cos(face), 0.0, sin(face))
|
||||||
|
var fwd := Vector3(-side.z, 0.0, side.x)
|
||||||
|
var h := rng.randf_range(0.32, 0.72)
|
||||||
|
var w := rng.randf_range(0.035, 0.06)
|
||||||
|
var bend := rng.randf_range(0.08, 0.28)
|
||||||
|
var b0 := pos - side * w
|
||||||
|
var b1 := pos + side * w
|
||||||
|
var mc := pos + fwd * bend * 0.35 + Vector3(0, h * 0.55, 0)
|
||||||
|
var m0 := mc - side * w * 0.7
|
||||||
|
var m1 := mc + side * w * 0.7
|
||||||
|
var tip := pos + fwd * bend + Vector3(0, h, 0)
|
||||||
|
var u := rng.randf()
|
||||||
|
var data := [[b0, 0.0], [b1, 0.0], [m1, 0.55], [b0, 0.0], [m1, 0.55], [m0, 0.55], [m0, 0.55], [m1, 0.55], [tip, 1.0]]
|
||||||
|
for d in data:
|
||||||
|
st.set_normal(Vector3.UP)
|
||||||
|
st.set_uv(Vector2(u, d[1]))
|
||||||
|
st.add_vertex(d[0])
|
||||||
|
return st.commit()
|
||||||
|
|
||||||
|
## Small flower: a stem (surface 0) and a five-petal head (surface 1, white so
|
||||||
|
## the MultiMesh instance colour tints it).
|
||||||
|
static func flower(seed: int) -> ArrayMesh:
|
||||||
|
var rng := RandomNumberGenerator.new()
|
||||||
|
rng.seed = seed
|
||||||
|
var stem := _begin()
|
||||||
|
var head := _begin()
|
||||||
|
var h := 0.32
|
||||||
|
var c := Vector3(0, h, 0)
|
||||||
|
_tri(stem, Vector3(-0.012, -0.05, 0), Vector3(0.012, -0.05, 0), c, Vector3.BACK, STEM)
|
||||||
|
_tri(stem, Vector3(0, -0.05, -0.012), Vector3(0, -0.05, 0.012), c, Vector3.RIGHT, STEM)
|
||||||
|
for k in range(5):
|
||||||
|
var a := TAU * k / 5.0
|
||||||
|
var tip := c + Vector3(cos(a) * 0.085, 0.025, sin(a) * 0.085)
|
||||||
|
var l := c + Vector3(cos(a - 0.45) * 0.045, 0.012, sin(a - 0.45) * 0.045)
|
||||||
|
var r := c + Vector3(cos(a + 0.45) * 0.045, 0.012, sin(a + 0.45) * 0.045)
|
||||||
|
_tri(head, c, l, tip, Vector3.UP, Color.WHITE)
|
||||||
|
_tri(head, c, tip, r, Vector3.UP, Color.WHITE)
|
||||||
|
var mesh := _commit(stem)
|
||||||
|
_commit(head, mesh)
|
||||||
|
return mesh
|
||||||
1
scripts/world/proc_mesh.gd.uid
Normal file
1
scripts/world/proc_mesh.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://bm5bwoxetmp4s
|
||||||
@@ -1,94 +1,502 @@
|
|||||||
extends Node3D
|
extends Node3D
|
||||||
## M0 mood props — a few low-poly trees and rocks built from primitives in code,
|
## Scenery: forests, rocks, cliff outcrops, bushes, logs, grass and flowers.
|
||||||
## so the scene looks "alive" with no imported assets. These are placeholders;
|
|
||||||
## real low-poly models (free asset packs, see docs/ROADMAP.md M0) replace them.
|
|
||||||
##
|
##
|
||||||
## place_on(terrain) is called by world.gd once the terrain exists, so each prop
|
## All placement is seeded, so a server and its clients would agree on where
|
||||||
## sits on the real ground height instead of floating at Y=0.
|
## every tree is (docs/ARCHITECTURE.md §4). Rendering uses MultiMesh batches
|
||||||
|
## bucketed into CELL x CELL world cells: a few hundred draw batches instead of
|
||||||
|
## thousands of nodes, and off-screen cells get frustum-culled.
|
||||||
|
##
|
||||||
|
## Trees, logs and larger rocks in or near the walkable valley get simple
|
||||||
|
## collision. Grass is streamed in tiles around the player because it's too
|
||||||
|
## dense to place over the whole map at once.
|
||||||
|
|
||||||
var _terrain: Node3D
|
const ProcMesh = preload("res://scripts/world/proc_mesh.gd")
|
||||||
|
|
||||||
func place_on(terrain: Node3D) -> void:
|
const CELL := 48.0
|
||||||
_terrain = terrain
|
const GRASS_TILE := 12.0
|
||||||
var rng := RandomNumberGenerator.new()
|
const FLOWER_COLORS := [
|
||||||
rng.seed = 42
|
Color(0.96, 0.95, 0.90), Color(0.98, 0.84, 0.25), Color(0.62, 0.42, 0.86),
|
||||||
for i in range(18):
|
Color(0.45, 0.60, 0.95), Color(0.93, 0.45, 0.55),
|
||||||
var x := rng.randf_range(-55.0, 55.0)
|
]
|
||||||
var z := rng.randf_range(-55.0, 55.0)
|
|
||||||
var y: float = _terrain.sample_height(x, z)
|
|
||||||
if rng.randf() < 0.65:
|
|
||||||
_make_tree(Vector3(x, y, z), rng)
|
|
||||||
else:
|
|
||||||
_make_rock(Vector3(x, y, z), rng)
|
|
||||||
|
|
||||||
func _make_tree(pos: Vector3, rng: RandomNumberGenerator) -> void:
|
@export var world_seed: int = 1337
|
||||||
|
@export var tree_spacing: float = 3.6
|
||||||
|
@export var forest_density: float = 1.0
|
||||||
|
@export var grass_spacing: float = 0.42
|
||||||
|
@export var grass_view_distance: float = 55.0
|
||||||
|
@export var grass_tiles_per_update: int = 6
|
||||||
|
|
||||||
|
var terrain: Node3D
|
||||||
|
var _player: Node3D
|
||||||
|
var _spawn := Vector3.ZERO
|
||||||
|
var _forest_noise := FastNoiseLite.new()
|
||||||
|
var _meadow_noise := FastNoiseLite.new()
|
||||||
|
var _colliders: StaticBody3D
|
||||||
|
|
||||||
|
var _bark_mat: StandardMaterial3D
|
||||||
|
var _leaf_mat: ShaderMaterial
|
||||||
|
var _rock_mat: ShaderMaterial
|
||||||
|
var _grass_mat: ShaderMaterial
|
||||||
|
|
||||||
|
var _grass_root: Node3D
|
||||||
|
var _grass_tiles: Dictionary = {} # Vector2i -> Node3D (or null for empty tiles)
|
||||||
|
var _grass_mesh: ArrayMesh
|
||||||
|
var _flower_mesh: ArrayMesh
|
||||||
|
var _grass_timer := 0.0
|
||||||
|
|
||||||
|
func place_on(t: Node3D, player: Node3D, spawn: Vector3) -> void:
|
||||||
|
terrain = t
|
||||||
|
_player = player
|
||||||
|
_spawn = spawn
|
||||||
|
_forest_noise.seed = world_seed + 11
|
||||||
|
_forest_noise.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH
|
||||||
|
_forest_noise.frequency = 0.011
|
||||||
|
_forest_noise.fractal_octaves = 3
|
||||||
|
_meadow_noise.seed = world_seed + 23
|
||||||
|
_meadow_noise.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH
|
||||||
|
_meadow_noise.frequency = 0.03
|
||||||
|
_meadow_noise.fractal_octaves = 2
|
||||||
|
|
||||||
|
_colliders = StaticBody3D.new()
|
||||||
|
_colliders.name = "PropColliders"
|
||||||
|
add_child(_colliders)
|
||||||
|
|
||||||
|
_make_materials()
|
||||||
|
_scatter_trees()
|
||||||
|
_scatter_rocks()
|
||||||
|
_scatter_bushes()
|
||||||
|
_place_logs()
|
||||||
|
|
||||||
|
_grass_root = Node3D.new()
|
||||||
|
_grass_root.name = "Grass"
|
||||||
|
add_child(_grass_root)
|
||||||
|
_grass_mesh = ProcMesh.grass_clump(world_seed + 5)
|
||||||
|
_grass_mesh.surface_set_material(0, _grass_mat)
|
||||||
|
_flower_mesh = ProcMesh.flower(world_seed + 6)
|
||||||
|
var stem := StandardMaterial3D.new()
|
||||||
|
stem.vertex_color_use_as_albedo = true
|
||||||
|
stem.vertex_color_is_srgb = true
|
||||||
|
stem.cull_mode = BaseMaterial3D.CULL_DISABLED
|
||||||
|
var head := stem.duplicate() as StandardMaterial3D
|
||||||
|
head.roughness = 0.7
|
||||||
|
_flower_mesh.surface_set_material(0, stem)
|
||||||
|
_flower_mesh.surface_set_material(1, head)
|
||||||
|
_update_grass(100000) # everything in range, before the first frame
|
||||||
|
|
||||||
|
func _process(delta: float) -> void:
|
||||||
|
if _player == null:
|
||||||
|
return
|
||||||
|
_grass_timer -= delta
|
||||||
|
if _grass_timer > 0.0:
|
||||||
|
return
|
||||||
|
_grass_timer = 0.2
|
||||||
|
_update_grass(grass_tiles_per_update)
|
||||||
|
|
||||||
|
# --- materials --------------------------------------------------------------
|
||||||
|
|
||||||
|
func _make_materials() -> void:
|
||||||
|
var noise_tex: Texture2D = terrain.noise_tex
|
||||||
|
_bark_mat = StandardMaterial3D.new()
|
||||||
|
_bark_mat.vertex_color_use_as_albedo = true
|
||||||
|
_bark_mat.vertex_color_is_srgb = true
|
||||||
|
_bark_mat.roughness = 1.0
|
||||||
|
|
||||||
|
_leaf_mat = ShaderMaterial.new()
|
||||||
|
_leaf_mat.shader = preload("res://shaders/foliage.gdshader")
|
||||||
|
_leaf_mat.set_shader_parameter("noise_tex", noise_tex)
|
||||||
|
|
||||||
|
_rock_mat = ShaderMaterial.new()
|
||||||
|
_rock_mat.shader = preload("res://shaders/rock.gdshader")
|
||||||
|
_rock_mat.set_shader_parameter("noise_tex", noise_tex)
|
||||||
|
_rock_mat.set_shader_parameter("snow_height", terrain.snow_height)
|
||||||
|
|
||||||
|
_grass_mat = ShaderMaterial.new()
|
||||||
|
_grass_mat.shader = preload("res://shaders/grass.gdshader")
|
||||||
|
_grass_mat.set_shader_parameter("noise_tex", noise_tex)
|
||||||
|
_grass_mat.set_shader_parameter("grass_lush", terrain.grass_lush)
|
||||||
|
_grass_mat.set_shader_parameter("grass_dry", terrain.grass_dry)
|
||||||
|
|
||||||
|
func _apply_materials(v: Dictionary) -> Dictionary:
|
||||||
|
var mesh: ArrayMesh = v.mesh
|
||||||
|
var surfaces: Array = v.surfaces
|
||||||
|
for i in range(surfaces.size()):
|
||||||
|
mesh.surface_set_material(i, _bark_mat if surfaces[i] == "bark" else _leaf_mat)
|
||||||
|
return v
|
||||||
|
|
||||||
|
# --- helpers ----------------------------------------------------------------
|
||||||
|
|
||||||
|
## Jittered grid covering the whole chunk.
|
||||||
|
func _jittered(spacing: float, rng: RandomNumberGenerator) -> PackedVector2Array:
|
||||||
|
var pts := PackedVector2Array()
|
||||||
|
var half: float = terrain.size * 0.5 - 2.0
|
||||||
|
var z := -half
|
||||||
|
while z < half:
|
||||||
|
var x := -half
|
||||||
|
while x < half:
|
||||||
|
pts.append(Vector2(x + rng.randf() * spacing, z + rng.randf() * spacing))
|
||||||
|
x += spacing
|
||||||
|
z += spacing
|
||||||
|
return pts
|
||||||
|
|
||||||
|
func _forest(x: float, z: float) -> float:
|
||||||
|
return smoothstep(-0.15, 0.35, _forest_noise.get_noise_2d(x, z))
|
||||||
|
|
||||||
|
func _group(groups: Dictionary, x: float, z: float, vi: int, xf: Transform3D) -> void:
|
||||||
|
var key := Vector3i(floori(x / CELL), floori(z / CELL), vi)
|
||||||
|
if not groups.has(key):
|
||||||
|
groups[key] = []
|
||||||
|
groups[key].append(xf)
|
||||||
|
|
||||||
|
func _flush(groups: Dictionary, meshes: Array, shadows: bool, label: String) -> void:
|
||||||
var root := Node3D.new()
|
var root := Node3D.new()
|
||||||
root.position = pos
|
root.name = label
|
||||||
add_child(root)
|
add_child(root)
|
||||||
|
for key: Vector3i in groups:
|
||||||
|
root.add_child(_mm_instance(meshes[key.z], groups[key], [], shadows))
|
||||||
|
|
||||||
var trunk := MeshInstance3D.new()
|
func _mm_instance(mesh: Mesh, xforms: Array, colors: Array, shadows: bool) -> MultiMeshInstance3D:
|
||||||
var tm := CylinderMesh.new()
|
var mm := MultiMesh.new()
|
||||||
tm.top_radius = 0.18
|
mm.transform_format = MultiMesh.TRANSFORM_3D
|
||||||
tm.bottom_radius = 0.28
|
mm.use_colors = not colors.is_empty() # must be set before instance_count
|
||||||
tm.height = 3.0
|
mm.mesh = mesh
|
||||||
trunk.mesh = tm
|
mm.instance_count = xforms.size()
|
||||||
trunk.position = Vector3(0, 1.5, 0)
|
for i in range(xforms.size()):
|
||||||
trunk.material_override = _mat(Color(0.33, 0.24, 0.16))
|
mm.set_instance_transform(i, xforms[i])
|
||||||
root.add_child(trunk)
|
if not colors.is_empty():
|
||||||
|
mm.set_instance_color(i, colors[i])
|
||||||
|
var mmi := MultiMeshInstance3D.new()
|
||||||
|
mmi.multimesh = mm
|
||||||
|
mmi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_ON if shadows else GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
||||||
|
return mmi
|
||||||
|
|
||||||
# Collision: a capsule around the trunk so you can't walk through the tree.
|
## Basis that tips `UP` part-way toward the ground normal, then yaws and scales.
|
||||||
var body := StaticBody3D.new()
|
func _ground_basis(normal: Vector3, follow: float, yaw: float, s: float) -> Basis:
|
||||||
root.add_child(body)
|
var up := Vector3.UP.lerp(normal, follow).normalized()
|
||||||
var col := CollisionShape3D.new()
|
var b := Basis(Vector3.UP, yaw)
|
||||||
var cap := CapsuleShape3D.new()
|
var axis := Vector3.UP.cross(up)
|
||||||
cap.radius = 0.35
|
if axis.length() > 0.0001:
|
||||||
cap.height = 4.0
|
b = Basis(axis.normalized(), Vector3.UP.angle_to(up)) * b
|
||||||
col.shape = cap
|
return b * Basis.from_scale(Vector3.ONE * s)
|
||||||
col.position = Vector3(0, 2.0, 0)
|
|
||||||
body.add_child(col)
|
|
||||||
|
|
||||||
# Low-poly conifer canopy: stacked cones.
|
func _add_shape(shape: Shape3D, xf: Transform3D) -> void:
|
||||||
var h := 3.0
|
var cs := CollisionShape3D.new()
|
||||||
for j in range(3):
|
cs.shape = shape
|
||||||
var cone := MeshInstance3D.new()
|
cs.transform = xf
|
||||||
var cm := CylinderMesh.new()
|
_colliders.add_child(cs)
|
||||||
cm.top_radius = 0.0
|
|
||||||
cm.bottom_radius = 1.6 - j * 0.4
|
|
||||||
cm.height = 1.6
|
|
||||||
cm.radial_segments = 6 # low-poly
|
|
||||||
cone.mesh = cm
|
|
||||||
cone.position = Vector3(0, h + j * 1.0, 0)
|
|
||||||
cone.material_override = _mat(Color(0.16, 0.34, 0.2))
|
|
||||||
root.add_child(cone)
|
|
||||||
|
|
||||||
root.rotate_y(rng.randf_range(0, TAU))
|
# --- trees ------------------------------------------------------------------
|
||||||
var s := rng.randf_range(0.85, 1.35)
|
|
||||||
root.scale = Vector3(s, s, s)
|
|
||||||
|
|
||||||
func _make_rock(pos: Vector3, rng: RandomNumberGenerator) -> void:
|
func _scatter_trees() -> void:
|
||||||
var rock := MeshInstance3D.new()
|
var variants: Array = []
|
||||||
var bm := BoxMesh.new()
|
var conifers: Array[int] = []
|
||||||
var box_size := Vector3(rng.randf_range(0.8, 1.8), rng.randf_range(0.6, 1.2), rng.randf_range(0.8, 1.8))
|
var broad: Array[int] = []
|
||||||
bm.size = box_size
|
var birches: Array[int] = []
|
||||||
rock.mesh = bm
|
for i in range(3):
|
||||||
rock.position = pos + Vector3(0, 0.3, 0)
|
conifers.append(variants.size()); variants.append(_apply_materials(ProcMesh.conifer(world_seed + 100 + i, false)))
|
||||||
rock.rotation = Vector3(rng.randf_range(-0.2, 0.2), rng.randf_range(0, TAU), rng.randf_range(-0.2, 0.2))
|
for i in range(2):
|
||||||
rock.material_override = _mat(Color(0.42, 0.42, 0.45))
|
conifers.append(variants.size()); variants.append(_apply_materials(ProcMesh.conifer(world_seed + 110 + i, true)))
|
||||||
add_child(rock)
|
for i in range(3):
|
||||||
|
var col: Color = ProcMesh.LEAF_GOLD if i == 2 else ProcMesh.LEAF_GREEN
|
||||||
|
broad.append(variants.size()); variants.append(_apply_materials(ProcMesh.broadleaf(world_seed + 120 + i, col)))
|
||||||
|
for i in range(2):
|
||||||
|
birches.append(variants.size()); variants.append(_apply_materials(ProcMesh.birch(world_seed + 130 + i)))
|
||||||
|
var dead := variants.size()
|
||||||
|
variants.append(_apply_materials(ProcMesh.dead_tree(world_seed + 140)))
|
||||||
|
|
||||||
# Collision: a box matching the rock mesh.
|
var rng := RandomNumberGenerator.new()
|
||||||
var body := StaticBody3D.new()
|
rng.seed = world_seed + 1
|
||||||
body.position = rock.position
|
var groups := {}
|
||||||
body.rotation = rock.rotation
|
for p in _jittered(tree_spacing, rng):
|
||||||
add_child(body)
|
# Draw every random number up front so the sequence never depends on
|
||||||
var col := CollisionShape3D.new()
|
# which branch below is taken (stable layout when rules are tweaked).
|
||||||
var box := BoxShape3D.new()
|
var roll := rng.randf()
|
||||||
box.size = box_size
|
var pick := rng.randf()
|
||||||
col.shape = box
|
var yaw := rng.randf() * TAU
|
||||||
body.add_child(col)
|
var s := rng.randf_range(0.75, 1.3)
|
||||||
|
var h: float = terrain.sample_height(p.x, p.y)
|
||||||
|
if roll > _tree_chance(p.x, p.y, h) * forest_density:
|
||||||
|
continue
|
||||||
|
var vi: int
|
||||||
|
var conifer_p := lerpf(0.4, 0.97, smoothstep(2.0, 24.0, h))
|
||||||
|
if pick < 0.025:
|
||||||
|
vi = dead
|
||||||
|
elif pick < conifer_p:
|
||||||
|
vi = conifers[int(pick * 9973.0) % conifers.size()]
|
||||||
|
elif (pick - conifer_p) / (1.0 - conifer_p) < 0.6:
|
||||||
|
vi = broad[int(pick * 7919.0) % broad.size()]
|
||||||
|
else:
|
||||||
|
vi = birches[int(pick * 7919.0) % birches.size()]
|
||||||
|
var xf := Transform3D(Basis(Vector3.UP, yaw) * Basis.from_scale(Vector3.ONE * s), Vector3(p.x, h - 0.1, p.y))
|
||||||
|
_group(groups, p.x, p.y, vi, xf)
|
||||||
|
if terrain.inside_boundary(p.x, p.y, -4.0):
|
||||||
|
var v: Dictionary = variants[vi]
|
||||||
|
var cap := CapsuleShape3D.new()
|
||||||
|
cap.radius = v.trunk_r * s
|
||||||
|
cap.height = maxf(v.trunk_h * s, cap.radius * 2.0 + 0.1)
|
||||||
|
_add_shape(cap, Transform3D(Basis(), Vector3(p.x, h + cap.height * 0.5, p.y)))
|
||||||
|
var meshes := variants.map(func(v): return v.mesh)
|
||||||
|
_flush(groups, meshes, true, "Trees")
|
||||||
|
|
||||||
func _mat(c: Color) -> StandardMaterial3D:
|
## Open grassy strip along the sea-cliff edge: no trees, bushes or boulders, so
|
||||||
var m := StandardMaterial3D.new()
|
## the view out to sea is clear and you can walk right up to the edge.
|
||||||
m.albedo_color = c
|
func _on_clifftop(x: float, z: float) -> bool:
|
||||||
m.roughness = 0.95
|
return terrain.sea_weight(x, z) > 0.5 and not terrain.inside_boundary(x, z, 10.0) \
|
||||||
return m
|
and terrain.inside_boundary(x, z, -8.0)
|
||||||
|
|
||||||
|
func _tree_chance(x: float, z: float, h: float) -> float:
|
||||||
|
if h < terrain.water_level + 0.8:
|
||||||
|
return 0.0
|
||||||
|
if Vector2(x - _spawn.x, z - _spawn.z).length() < 9.0 or _on_clifftop(x, z):
|
||||||
|
return 0.0
|
||||||
|
var n: Vector3 = terrain.sample_normal(x, z)
|
||||||
|
var slope := 1.0 - n.y
|
||||||
|
if slope > 0.5:
|
||||||
|
return 0.0
|
||||||
|
var c := lerpf(0.035, 0.8, _forest(x, z))
|
||||||
|
c *= 1.0 - smoothstep(0.25, 0.5, slope)
|
||||||
|
c *= 1.0 - smoothstep(terrain.snow_height - 12.0, terrain.snow_height - 2.0, h)
|
||||||
|
return c
|
||||||
|
|
||||||
|
# --- rocks ------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _scatter_rocks() -> void:
|
||||||
|
var meshes: Array = []
|
||||||
|
var hulls: Array = []
|
||||||
|
for i in range(7):
|
||||||
|
var m := ProcMesh.rock(world_seed + 300 + i, i >= 5)
|
||||||
|
m.surface_set_material(0, _rock_mat)
|
||||||
|
meshes.append(m)
|
||||||
|
hulls.append((m.create_convex_shape(true, true) as ConvexPolygonShape3D).points)
|
||||||
|
var rng := RandomNumberGenerator.new()
|
||||||
|
rng.seed = world_seed + 2
|
||||||
|
var groups := {}
|
||||||
|
|
||||||
|
# Small stones: everywhere, more on slopes and in the shallows.
|
||||||
|
for p in _jittered(6.0, rng):
|
||||||
|
var roll := rng.randf(); var vi := rng.randi_range(0, 4); var yaw := rng.randf() * TAU
|
||||||
|
var s := rng.randf_range(0.2, 0.75)
|
||||||
|
var h: float = terrain.sample_height(p.x, p.y)
|
||||||
|
var n: Vector3 = terrain.sample_normal(p.x, p.y)
|
||||||
|
var chance := 0.1 + (1.0 - n.y) * 0.8
|
||||||
|
if h < terrain.water_level - 2.0 or roll > chance:
|
||||||
|
continue
|
||||||
|
_place_rock(groups, meshes, hulls, p, h, n, vi, yaw, s, 0.6, 0.25)
|
||||||
|
|
||||||
|
# Boulders: sparse, a little more common in forests.
|
||||||
|
for p in _jittered(14.0, rng):
|
||||||
|
var roll := rng.randf(); var vi := rng.randi_range(0, 4); var yaw := rng.randf() * TAU
|
||||||
|
var s := rng.randf_range(1.2, 3.0)
|
||||||
|
var h: float = terrain.sample_height(p.x, p.y)
|
||||||
|
var n: Vector3 = terrain.sample_normal(p.x, p.y)
|
||||||
|
if h < terrain.water_level - 0.5 or Vector2(p.x - _spawn.x, p.y - _spawn.z).length() < 8.0 or _on_clifftop(p.x, p.y):
|
||||||
|
continue
|
||||||
|
if roll > 0.07 + _forest(p.x, p.y) * 0.08:
|
||||||
|
continue
|
||||||
|
_place_rock(groups, meshes, hulls, p, h, n, vi, yaw, s, 0.5, 0.3)
|
||||||
|
|
||||||
|
# Cliff outcrops: big slabs breaking up the steep mountain faces.
|
||||||
|
for p in _jittered(8.0, rng):
|
||||||
|
var roll := rng.randf(); var vi := rng.randi_range(0, 6); var yaw := rng.randf() * TAU
|
||||||
|
var s := rng.randf_range(3.0, 8.0)
|
||||||
|
var n: Vector3 = terrain.sample_normal(p.x, p.y)
|
||||||
|
if 1.0 - n.y < 0.32 or roll > 0.22:
|
||||||
|
continue
|
||||||
|
var h: float = terrain.sample_height(p.x, p.y)
|
||||||
|
_place_rock(groups, meshes, hulls, p, h, n, vi, yaw, s, 0.9, 0.6)
|
||||||
|
|
||||||
|
# Sea stacks: tall pillars standing off the cliff (no collision, unreachable).
|
||||||
|
var placed := 0
|
||||||
|
var tries := 0
|
||||||
|
while placed < 9 and tries < 200:
|
||||||
|
tries += 1
|
||||||
|
var ang: float = terrain.sea_angle_deg + rng.randf_range(-1.0, 1.0) * (terrain.sea_half_width_deg + 8.0)
|
||||||
|
var p: Vector2 = terrain.coast_point(ang, rng.randf_range(10.0, 48.0))
|
||||||
|
var vi := rng.randi_range(5, 6)
|
||||||
|
var yaw := rng.randf() * TAU
|
||||||
|
var s := rng.randf_range(3.5, 7.0)
|
||||||
|
var h: float = terrain.sample_height(p.x, p.y)
|
||||||
|
if h > terrain.sea_level - 3.0:
|
||||||
|
continue
|
||||||
|
var b := Basis(Vector3.UP, yaw) * Basis.from_scale(Vector3(s, s * rng.randf_range(1.8, 2.6), s))
|
||||||
|
_group(groups, p.x, p.y, vi, Transform3D(b, Vector3(p.x, h - 1.0, p.y)))
|
||||||
|
placed += 1
|
||||||
|
|
||||||
|
_flush(groups, meshes, true, "Rocks")
|
||||||
|
|
||||||
|
func _place_rock(groups: Dictionary, meshes: Array, hulls: Array, p: Vector2, h: float, n: Vector3,
|
||||||
|
vi: int, yaw: float, s: float, follow: float, sink: float) -> void:
|
||||||
|
var b := _ground_basis(n, follow, yaw, s)
|
||||||
|
var pos := Vector3(p.x, h - sink * s, p.y)
|
||||||
|
_group(groups, p.x, p.y, vi, Transform3D(b, pos))
|
||||||
|
if s >= 0.55 and terrain.inside_boundary(p.x, p.y, -6.0):
|
||||||
|
var pts := PackedVector3Array()
|
||||||
|
for q: Vector3 in hulls[vi]:
|
||||||
|
pts.append(q * s)
|
||||||
|
var shape := ConvexPolygonShape3D.new()
|
||||||
|
shape.points = pts
|
||||||
|
_add_shape(shape, Transform3D(b.orthonormalized(), pos))
|
||||||
|
|
||||||
|
# --- bushes, logs, stumps ---------------------------------------------------
|
||||||
|
|
||||||
|
func _scatter_bushes() -> void:
|
||||||
|
var meshes: Array = []
|
||||||
|
for i in range(3):
|
||||||
|
meshes.append(_apply_materials(ProcMesh.bush(world_seed + 200 + i)).mesh)
|
||||||
|
var rng := RandomNumberGenerator.new()
|
||||||
|
rng.seed = world_seed + 3
|
||||||
|
var groups := {}
|
||||||
|
for p in _jittered(4.5, rng):
|
||||||
|
var roll := rng.randf(); var vi := rng.randi_range(0, 2); var yaw := rng.randf() * TAU
|
||||||
|
var s := rng.randf_range(0.7, 1.5)
|
||||||
|
var h: float = terrain.sample_height(p.x, p.y)
|
||||||
|
var n: Vector3 = terrain.sample_normal(p.x, p.y)
|
||||||
|
if h < terrain.water_level + 0.5 or n.y < 0.75 or h > terrain.snow_height - 8.0:
|
||||||
|
continue
|
||||||
|
if Vector2(p.x - _spawn.x, p.y - _spawn.z).length() < 4.0 or _on_clifftop(p.x, p.y):
|
||||||
|
continue
|
||||||
|
var f := _forest(p.x, p.y)
|
||||||
|
var edge := 1.0 - absf(f - 0.5) * 2.0 # forest edges get the most undergrowth
|
||||||
|
if roll > 0.05 + 0.4 * edge:
|
||||||
|
continue
|
||||||
|
_group(groups, p.x, p.y, vi, Transform3D(_ground_basis(n, 0.5, yaw, s), Vector3(p.x, h - 0.1, p.y)))
|
||||||
|
_flush(groups, meshes, true, "Bushes")
|
||||||
|
|
||||||
|
func _place_logs() -> void:
|
||||||
|
var root := Node3D.new()
|
||||||
|
root.name = "Logs"
|
||||||
|
add_child(root)
|
||||||
|
var rng := RandomNumberGenerator.new()
|
||||||
|
rng.seed = world_seed + 4
|
||||||
|
var logs := 0
|
||||||
|
var stumps := 0
|
||||||
|
var tries := 0
|
||||||
|
while (logs < 22 or stumps < 30) and tries < 4000:
|
||||||
|
tries += 1
|
||||||
|
var x := rng.randf_range(-terrain.play_radius, terrain.play_radius)
|
||||||
|
var z := rng.randf_range(-terrain.play_radius, terrain.play_radius)
|
||||||
|
var yaw := rng.randf() * TAU
|
||||||
|
var make_log := rng.randf() < 0.45
|
||||||
|
if not terrain.inside_boundary(x, z, 6.0) or _forest(x, z) < 0.35:
|
||||||
|
continue
|
||||||
|
var h: float = terrain.sample_height(x, z)
|
||||||
|
var n: Vector3 = terrain.sample_normal(x, z)
|
||||||
|
if h < terrain.water_level + 0.6 or n.y < 0.97 or Vector2(x - _spawn.x, z - _spawn.z).length() < 8.0:
|
||||||
|
continue
|
||||||
|
var mi := MeshInstance3D.new()
|
||||||
|
var shape: Shape3D
|
||||||
|
var xf: Transform3D
|
||||||
|
if make_log and logs < 22:
|
||||||
|
var length := rng.randf_range(3.0, 5.0)
|
||||||
|
var r := rng.randf_range(0.22, 0.34)
|
||||||
|
mi.mesh = ProcMesh.log_mesh(world_seed + 400 + logs, length, r)
|
||||||
|
xf = Transform3D(_ground_basis(n, 1.0, yaw, 1.0), Vector3(x, h + r * 0.75, z))
|
||||||
|
var box := BoxShape3D.new()
|
||||||
|
box.size = Vector3(length, r * 1.8, r * 1.8)
|
||||||
|
shape = box
|
||||||
|
logs += 1
|
||||||
|
elif not make_log and stumps < 30:
|
||||||
|
var sh := rng.randf_range(0.35, 0.7)
|
||||||
|
var r := rng.randf_range(0.25, 0.4)
|
||||||
|
mi.mesh = ProcMesh.stump_mesh(world_seed + 500 + stumps, sh, r)
|
||||||
|
xf = Transform3D(Basis(Vector3.UP, yaw), Vector3(x, h, z))
|
||||||
|
var cyl := CylinderShape3D.new()
|
||||||
|
cyl.radius = r * 1.1
|
||||||
|
cyl.height = sh + 0.3
|
||||||
|
shape = cyl
|
||||||
|
stumps += 1
|
||||||
|
else:
|
||||||
|
mi.free()
|
||||||
|
continue
|
||||||
|
mi.material_override = _bark_mat
|
||||||
|
mi.transform = xf
|
||||||
|
root.add_child(mi)
|
||||||
|
var col_xf := xf
|
||||||
|
if shape is CylinderShape3D:
|
||||||
|
col_xf.origin.y += (shape as CylinderShape3D).height * 0.5 - 0.3
|
||||||
|
_add_shape(shape, col_xf)
|
||||||
|
|
||||||
|
# --- grass streaming --------------------------------------------------------
|
||||||
|
|
||||||
|
func _update_grass(budget: int) -> void:
|
||||||
|
var p := _player.global_position
|
||||||
|
var reach := grass_view_distance + GRASS_TILE
|
||||||
|
var c := Vector2i(floori(p.x / GRASS_TILE), floori(p.z / GRASS_TILE))
|
||||||
|
var tr := int(ceil(reach / GRASS_TILE))
|
||||||
|
var wanted := {}
|
||||||
|
var todo: Array = []
|
||||||
|
for dz in range(-tr, tr + 1):
|
||||||
|
for dx in range(-tr, tr + 1):
|
||||||
|
var k := c + Vector2i(dx, dz)
|
||||||
|
var d := Vector2((k.x + 0.5) * GRASS_TILE, (k.y + 0.5) * GRASS_TILE).distance_to(Vector2(p.x, p.z))
|
||||||
|
if d > reach:
|
||||||
|
continue
|
||||||
|
wanted[k] = true
|
||||||
|
if not _grass_tiles.has(k):
|
||||||
|
todo.append([d, k])
|
||||||
|
todo.sort_custom(func(a, b): return a[0] < b[0])
|
||||||
|
for i in range(mini(budget, todo.size())):
|
||||||
|
var k: Vector2i = todo[i][1]
|
||||||
|
_grass_tiles[k] = _make_grass_tile(k)
|
||||||
|
for k: Vector2i in _grass_tiles.keys():
|
||||||
|
if wanted.has(k):
|
||||||
|
continue
|
||||||
|
var d := Vector2((k.x + 0.5) * GRASS_TILE, (k.y + 0.5) * GRASS_TILE).distance_to(Vector2(p.x, p.z))
|
||||||
|
if d > reach + GRASS_TILE * 1.5:
|
||||||
|
var node = _grass_tiles[k]
|
||||||
|
if node != null and is_instance_valid(node):
|
||||||
|
node.queue_free()
|
||||||
|
_grass_tiles.erase(k)
|
||||||
|
|
||||||
|
func _make_grass_tile(k: Vector2i) -> Node3D:
|
||||||
|
var rng := RandomNumberGenerator.new()
|
||||||
|
rng.seed = hash(k) ^ world_seed
|
||||||
|
var ox := k.x * GRASS_TILE
|
||||||
|
var oz := k.y * GRASS_TILE
|
||||||
|
var centre := Vector3(ox + GRASS_TILE * 0.5, 0.0, oz + GRASS_TILE * 0.5)
|
||||||
|
var grass_xf: Array = []
|
||||||
|
var flower_xf: Array = []
|
||||||
|
var flower_col: Array = []
|
||||||
|
var steps := int(GRASS_TILE / grass_spacing)
|
||||||
|
for iz in range(steps):
|
||||||
|
for ix in range(steps):
|
||||||
|
var x := ox + (ix + rng.randf()) * grass_spacing
|
||||||
|
var z := oz + (iz + rng.randf()) * grass_spacing
|
||||||
|
var keep := rng.randf(); var yaw := rng.randf() * TAU
|
||||||
|
var s := rng.randf_range(0.75, 1.25); var fl := rng.randf()
|
||||||
|
if not terrain.in_bounds(x, z):
|
||||||
|
continue
|
||||||
|
var h: float = terrain.sample_height(x, z)
|
||||||
|
if h < terrain.water_level + 0.25 or h > terrain.snow_height - 6.0:
|
||||||
|
continue
|
||||||
|
var n: Vector3 = terrain.sample_normal(x, z)
|
||||||
|
if n.y < 0.72:
|
||||||
|
continue
|
||||||
|
var meadow := _meadow_noise.get_noise_2d(x, z)
|
||||||
|
var dens := smoothstep(-0.45, 0.1, meadow) * (1.0 - smoothstep(0.85, 0.72, n.y) * 0.7)
|
||||||
|
if keep > dens:
|
||||||
|
continue
|
||||||
|
var tall := lerpf(0.7, 1.35, smoothstep(-0.1, 0.4, meadow))
|
||||||
|
var b := Basis(Vector3.UP, yaw) * Basis.from_scale(Vector3(s, s * tall, s))
|
||||||
|
grass_xf.append(Transform3D(b, Vector3(x - centre.x, h - 0.03, z - centre.z)))
|
||||||
|
if fl < 0.02 * smoothstep(0.0, 0.4, meadow):
|
||||||
|
var ci := int(absf(_forest_noise.get_noise_2d(x * 3.0, z * 3.0)) * 10.0) % FLOWER_COLORS.size()
|
||||||
|
flower_xf.append(Transform3D(Basis(Vector3.UP, yaw) * Basis.from_scale(Vector3.ONE * s), Vector3(x - centre.x, h, z - centre.z)))
|
||||||
|
flower_col.append(FLOWER_COLORS[ci])
|
||||||
|
if grass_xf.is_empty():
|
||||||
|
return null
|
||||||
|
var node := Node3D.new()
|
||||||
|
node.position = centre
|
||||||
|
var g := _mm_instance(_grass_mesh, grass_xf, [], false)
|
||||||
|
_fade(g, grass_view_distance)
|
||||||
|
node.add_child(g)
|
||||||
|
if not flower_xf.is_empty():
|
||||||
|
var f := _mm_instance(_flower_mesh, flower_xf, flower_col, false)
|
||||||
|
_fade(f, grass_view_distance * 0.7)
|
||||||
|
node.add_child(f)
|
||||||
|
_grass_root.add_child(node)
|
||||||
|
return node
|
||||||
|
|
||||||
|
func _fade(gi: GeometryInstance3D, dist: float) -> void:
|
||||||
|
gi.visibility_range_end = dist
|
||||||
|
gi.visibility_range_end_margin = 10.0
|
||||||
|
gi.visibility_range_fade_mode = GeometryInstance3D.VISIBILITY_RANGE_FADE_SELF
|
||||||
|
|||||||
1
scripts/world/props.gd.uid
Normal file
1
scripts/world/props.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://0o8ocjpya04q
|
||||||
@@ -1,31 +1,78 @@
|
|||||||
extends Node3D
|
extends Node3D
|
||||||
## Terrain generator — M0 placeholder.
|
## Terrain generator.
|
||||||
##
|
##
|
||||||
## Builds ONE heightmap chunk from deterministic noise, so the world already
|
## One heightmap chunk built from deterministic noise: a walkable valley of
|
||||||
## looks like rolling ground rather than a flat plane. Single chunk, generated at
|
## rolling hills with a lake, enclosed by a ring of steep mountains that opens on
|
||||||
## _ready(). Chunk streaming, biomes and server-side determinism (see
|
## one side to a sheer sea cliff. The whole chunk is an island: its edge sinks
|
||||||
## docs/ARCHITECTURE.md §4) come in later milestones.
|
## into ocean so the end of the mesh is never visible.
|
||||||
##
|
##
|
||||||
## Collision uses MeshInstance3D.create_trimesh_collision() (mesh-derived), which
|
## The rim is the world boundary: mountain slopes are too steep to walk up
|
||||||
## registers reliably in the physics space. A hand-built ConcavePolygonShape3D
|
## (> floor_max_angle), and an invisible wall stands partway up them, or just
|
||||||
## via set_faces() did NOT register (player/rays passed through) — avoid it.
|
## inside the cliff edge on the sea side, so nobody can climb out or fall off. Chunk
|
||||||
|
## streaming and biomes come in later milestones (docs/ARCHITECTURE.md §4).
|
||||||
##
|
##
|
||||||
## Exposes `sample_height(x, z)` so other systems (player spawn, prop placement)
|
## Heights are computed once into a grid that feeds the render mesh, the
|
||||||
## can sit exactly on the surface.
|
## collision shape and `sample_height()`, so props and the player sit exactly on
|
||||||
|
## the visible surface.
|
||||||
|
##
|
||||||
|
## Collision is a HeightMapShape3D. Do NOT swap it for a concave trimesh: those
|
||||||
|
## are one-sided and the player fell straight through them from above.
|
||||||
|
|
||||||
signal ready_built ## emitted once the mesh + collision exist
|
signal ready_built ## emitted once the mesh + collision exist
|
||||||
|
|
||||||
@export var size: int = 128 # chunk is size x size metres
|
@export var size: int = 320 ## chunk is size x size metres
|
||||||
@export var resolution: int = 96 # vertices per side (higher = smoother/heavier)
|
@export var resolution: int = 256 ## grid cells per side
|
||||||
@export var height_scale: float = 10.0
|
@export var height_scale: float = 9.0 ## rolling-hill amplitude in the valley
|
||||||
@export var noise_seed: int = 1337
|
@export var noise_seed: int = 1337
|
||||||
|
|
||||||
var _noise: FastNoiseLite
|
@export_group("Boundary")
|
||||||
|
@export var play_radius: float = 100.0 ## where the mountain rim starts rising
|
||||||
|
@export var rim_width: float = 30.0 ## horizontal run of the rim's steep face
|
||||||
|
@export var mountain_height: float = 58.0
|
||||||
|
@export var rim_warp: float = 16.0 ## how irregular the rim's outline is
|
||||||
|
@export_range(0.2, 0.9) var wall_frac: float = 0.45 ## invisible wall, as a fraction up the rim
|
||||||
|
|
||||||
|
@export_group("Sea cliff")
|
||||||
|
@export var sea_angle_deg: float = -75.0 ## direction of the coast (atan2(z, x); -90 = straight -Z)
|
||||||
|
@export var sea_half_width_deg: float = 38.0 ## full-sea arc either side of sea_angle
|
||||||
|
@export var sea_fade_deg: float = 22.0 ## headland transition back to mountains
|
||||||
|
@export var sea_level: float = -24.0
|
||||||
|
@export var cliff_top: float = 0.5 ## height the coast rises/falls to at the edge
|
||||||
|
@export var cliff_offset: float = 4.0 ## cliff edge, in rim distance past play_radius
|
||||||
|
@export var cliff_drop_width: float = 6.0 ## horizontal run of the drop (smaller = sheerer)
|
||||||
|
|
||||||
|
@export_group("Water & snow")
|
||||||
|
@export var water_level: float = -2.6
|
||||||
|
@export var lake_center: Vector2 = Vector2(14.0, -46.0)
|
||||||
|
@export var lake_radius: float = 24.0
|
||||||
|
@export var snow_height: float = 40.0
|
||||||
|
|
||||||
|
@export_group("Colours")
|
||||||
|
@export var grass_lush: Color = Color(0.25, 0.40, 0.15)
|
||||||
|
@export var grass_dry: Color = Color(0.42, 0.49, 0.21)
|
||||||
|
|
||||||
var built: bool = false
|
var built: bool = false
|
||||||
|
var min_height: float = 0.0
|
||||||
|
var max_height: float = 0.0
|
||||||
|
var step: float = 1.0
|
||||||
|
var half: float = 0.0
|
||||||
|
var material: ShaderMaterial
|
||||||
|
var noise_tex: NoiseTexture2D ## shared by every world shader (see common.gdshaderinc)
|
||||||
|
var normal_tex: NoiseTexture2D ## detail normal map for ground and water
|
||||||
|
|
||||||
|
var _noise: FastNoiseLite
|
||||||
|
var _detail: FastNoiseLite
|
||||||
|
var _ridge: FastNoiseLite
|
||||||
|
var _warp: FastNoiseLite
|
||||||
|
var _wall_ring: Array[Vector3] = []
|
||||||
|
var _heights := PackedFloat32Array()
|
||||||
|
var _normals := PackedVector3Array()
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
_build()
|
_build()
|
||||||
|
|
||||||
|
# --- height function --------------------------------------------------------
|
||||||
|
|
||||||
func _ensure_noise() -> void:
|
func _ensure_noise() -> void:
|
||||||
if _noise != null:
|
if _noise != null:
|
||||||
return
|
return
|
||||||
@@ -35,95 +82,379 @@ func _ensure_noise() -> void:
|
|||||||
_noise.frequency = 0.012
|
_noise.frequency = 0.012
|
||||||
_noise.fractal_octaves = 4
|
_noise.fractal_octaves = 4
|
||||||
|
|
||||||
|
_detail = FastNoiseLite.new()
|
||||||
|
_detail.seed = noise_seed + 1
|
||||||
|
_detail.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH
|
||||||
|
_detail.frequency = 0.06
|
||||||
|
_detail.fractal_octaves = 2
|
||||||
|
|
||||||
|
_ridge = FastNoiseLite.new()
|
||||||
|
_ridge.seed = noise_seed + 2
|
||||||
|
_ridge.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH
|
||||||
|
_ridge.frequency = 0.022
|
||||||
|
_ridge.fractal_octaves = 4
|
||||||
|
|
||||||
|
_warp = FastNoiseLite.new()
|
||||||
|
_warp.seed = noise_seed + 3
|
||||||
|
_warp.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH
|
||||||
|
_warp.frequency = 0.03
|
||||||
|
|
||||||
|
## Radial offset that makes the rim wobble instead of being a perfect circle.
|
||||||
|
## Sampled around a circle, so it depends only on the angle from the centre.
|
||||||
|
func _rim_offset(x: float, z: float) -> float:
|
||||||
|
var ang := atan2(z, x)
|
||||||
|
return _warp.get_noise_2d(cos(ang) * 50.0, sin(ang) * 50.0) * rim_warp
|
||||||
|
|
||||||
|
## "Rim distance": distance from the centre plus the wobble. The rim starts at
|
||||||
|
## play_radius and the invisible wall stands at _wall_d() in this same measure.
|
||||||
|
func _rim_d(x: float, z: float) -> float:
|
||||||
|
return sqrt(x * x + z * z) + _rim_offset(x, z)
|
||||||
|
|
||||||
|
func _wall_d() -> float:
|
||||||
|
return play_radius + rim_width * wall_frac
|
||||||
|
|
||||||
|
func _cliff_d() -> float:
|
||||||
|
return play_radius + cliff_offset
|
||||||
|
|
||||||
|
## 1 on the open-sea side of the rim, 0 where the mountains are, smooth between
|
||||||
|
## (the headlands). Depends only on the angle from the centre.
|
||||||
|
func _sea_w(x: float, z: float) -> float:
|
||||||
|
var diff := absf(wrapf(atan2(z, x) - deg_to_rad(sea_angle_deg), -PI, PI))
|
||||||
|
return 1.0 - smoothstep(deg_to_rad(sea_half_width_deg), deg_to_rad(sea_half_width_deg + sea_fade_deg), diff)
|
||||||
|
|
||||||
|
## Boundary threshold in rim distance: partway up the mountains, or just
|
||||||
|
## inside the cliff edge on the sea side.
|
||||||
|
func _wall_threshold(x: float, z: float) -> float:
|
||||||
|
return lerpf(_wall_d(), _cliff_d() - 1.0, _sea_w(x, z))
|
||||||
|
|
||||||
|
func _height_fn(x: float, z: float) -> float:
|
||||||
|
_ensure_noise()
|
||||||
|
var valley := _noise.get_noise_2d(x, z) * height_scale
|
||||||
|
valley += _detail.get_noise_2d(x, z) * 1.4
|
||||||
|
var d := _rim_d(x, z)
|
||||||
|
|
||||||
|
# Mountain rim.
|
||||||
|
var land := valley
|
||||||
|
var t := smoothstep(play_radius, play_radius + rim_width, d)
|
||||||
|
if t > 0.0:
|
||||||
|
var ridge := 1.0 - absf(_ridge.get_noise_2d(x, z))
|
||||||
|
var m := pow(t, 1.4) * mountain_height * (0.65 + 0.55 * ridge)
|
||||||
|
m += maxf(d - (play_radius + rim_width), 0.0) * 0.35 * (0.5 + ridge)
|
||||||
|
# Stepped ledges so the faces read as layered cliffs, not smooth ramps.
|
||||||
|
var band := 6.0
|
||||||
|
var stepped := floorf(m / band) * band + smoothstep(0.35, 0.65, fposmod(m, band) / band) * band
|
||||||
|
m = lerpf(m, stepped, 0.45 * t)
|
||||||
|
land = valley * (1.0 - t * 0.6) + m
|
||||||
|
|
||||||
|
# Sea side: the valley eases to a cliff top, then drops sheer to the seabed.
|
||||||
|
# The edge sits 1 m past the invisible wall at every angle, including the
|
||||||
|
# headlands where the wall moves outward. (With the edge fixed at _cliff_d(),
|
||||||
|
# the headlands dropped away INSIDE the wall: pits you could fall into, with
|
||||||
|
# lake water hanging over them. tests/test_terrain_edges.gd guards this.)
|
||||||
|
var sw := _sea_w(x, z)
|
||||||
|
if sw > 0.0:
|
||||||
|
var edge := _wall_threshold(x, z) + 1.0
|
||||||
|
var top := cliff_top + _detail.get_noise_2d(x * 0.5, z * 0.5) * 1.5
|
||||||
|
var coast := lerpf(valley, top, smoothstep(play_radius - 45.0, edge, d))
|
||||||
|
var seabed := sea_level - 7.0 - minf(maxf(d - edge, 0.0), 60.0) * 0.08 + _detail.get_noise_2d(x, z) * 2.0
|
||||||
|
coast = lerpf(coast, seabed, smoothstep(edge, edge + cliff_drop_width, d))
|
||||||
|
land = lerpf(land, coast, sw)
|
||||||
|
|
||||||
|
# Lake basin: blend the ground down to a bed below the waterline.
|
||||||
|
var ld := Vector2(x - lake_center.x, z - lake_center.y).length()
|
||||||
|
ld += _detail.get_noise_2d(x * 0.5, z * 0.5) * 5.0
|
||||||
|
land = lerpf(land, water_level - 3.2, smoothstep(lake_radius, lake_radius * 0.35, ld))
|
||||||
|
|
||||||
|
# Ocean all round the chunk edge, so the world is an island and the edge of
|
||||||
|
# the mesh is always hidden under deep water.
|
||||||
|
var e := smoothstep(size * 0.5 - 28.0, size * 0.5 - 4.0, sqrt(x * x + z * z))
|
||||||
|
return lerpf(land, sea_level - 10.0 + _detail.get_noise_2d(x, z) * 2.0, e)
|
||||||
|
|
||||||
|
# --- build ------------------------------------------------------------------
|
||||||
|
|
||||||
func _build() -> void:
|
func _build() -> void:
|
||||||
_ensure_noise()
|
_ensure_noise()
|
||||||
|
step = float(size) / float(resolution)
|
||||||
|
half = size * 0.5
|
||||||
|
var n := resolution + 1
|
||||||
|
|
||||||
var st := SurfaceTool.new()
|
_heights.resize(n * n)
|
||||||
st.begin(Mesh.PRIMITIVE_TRIANGLES)
|
min_height = INF
|
||||||
|
max_height = -INF
|
||||||
|
for iz in range(n):
|
||||||
|
for ix in range(n):
|
||||||
|
var hv := _height_fn(ix * step - half, iz * step - half)
|
||||||
|
_heights[iz * n + ix] = hv
|
||||||
|
min_height = minf(min_height, hv)
|
||||||
|
max_height = maxf(max_height, hv)
|
||||||
|
|
||||||
var step := float(size) / float(resolution)
|
_normals.resize(n * n)
|
||||||
var half := size * 0.5
|
for iz in range(n):
|
||||||
var uv_scale := 1.0 / float(size)
|
for ix in range(n):
|
||||||
|
var hl := _heights[iz * n + maxi(ix - 1, 0)]
|
||||||
|
var hr := _heights[iz * n + mini(ix + 1, resolution)]
|
||||||
|
var hd := _heights[maxi(iz - 1, 0) * n + ix]
|
||||||
|
var hu := _heights[mini(iz + 1, resolution) * n + ix]
|
||||||
|
_normals[iz * n + ix] = Vector3(hl - hr, 2.0 * step, hd - hu).normalized()
|
||||||
|
|
||||||
for z in range(resolution):
|
_make_textures()
|
||||||
for x in range(resolution):
|
_build_mesh(n)
|
||||||
var x0 := x * step - half
|
_build_collision(n)
|
||||||
var z0 := z * step - half
|
_build_boundary()
|
||||||
var x1 := (x + 1) * step - half
|
_build_water()
|
||||||
var z1 := (z + 1) * step - half
|
|
||||||
|
|
||||||
var p00 := Vector3(x0, sample_height(x0, z0), z0)
|
|
||||||
var p10 := Vector3(x1, sample_height(x1, z0), z0)
|
|
||||||
var p01 := Vector3(x0, sample_height(x0, z1), z1)
|
|
||||||
var p11 := Vector3(x1, sample_height(x1, z1), z1)
|
|
||||||
|
|
||||||
# Winding order chosen so generate_normals() produces UPWARD normals.
|
|
||||||
# (The reverse winding gave downward normals, so the sun lit the
|
|
||||||
# underside and the top surface rendered black — confirmed by an
|
|
||||||
# unshaded render showing the true bright-green albedo.)
|
|
||||||
_add_tri(st, p00, p11, p01, half, uv_scale)
|
|
||||||
_add_tri(st, p00, p10, p11, half, uv_scale)
|
|
||||||
|
|
||||||
st.generate_normals()
|
|
||||||
|
|
||||||
var mesh := st.commit()
|
|
||||||
var mi := MeshInstance3D.new()
|
|
||||||
mi.name = "TerrainMesh"
|
|
||||||
mi.mesh = mesh
|
|
||||||
var mat := StandardMaterial3D.new()
|
|
||||||
mat.albedo_color = Color(0.36, 0.47, 0.26) # mossy green (lit, not fluorescent)
|
|
||||||
mat.roughness = 0.95
|
|
||||||
mi.material_override = mat
|
|
||||||
add_child(mi)
|
|
||||||
|
|
||||||
# Collision: a HeightMapShape3D, NOT a concave trimesh.
|
|
||||||
#
|
|
||||||
# Root cause of the fall-through (confirmed by instrumented tests): a concave
|
|
||||||
# polygon shape — whether hand-built via set_faces() or mesh-derived via
|
|
||||||
# create_trimesh_collision() — is ONE-SIDED. It only collides on the front of
|
|
||||||
# each triangle, so with this mesh's winding the player dropped straight
|
|
||||||
# through from above. A box on the same StaticBody collided fine, which pinned
|
|
||||||
# the cause to the concave shape, not layers/timing/registration.
|
|
||||||
#
|
|
||||||
# HeightMapShape3D is the correct primitive for heightmap terrain: a grid of
|
|
||||||
# height samples, solid (two-sided), and far cheaper than a trimesh.
|
|
||||||
var hm := HeightMapShape3D.new()
|
|
||||||
hm.map_width = resolution + 1
|
|
||||||
hm.map_depth = resolution + 1
|
|
||||||
var data := PackedFloat32Array()
|
|
||||||
data.resize((resolution + 1) * (resolution + 1))
|
|
||||||
var hi := 0
|
|
||||||
for hz in range(resolution + 1):
|
|
||||||
for hx in range(resolution + 1):
|
|
||||||
var wx := hx * step - half
|
|
||||||
var wz := hz * step - half
|
|
||||||
data[hi] = sample_height(wx, wz)
|
|
||||||
hi += 1
|
|
||||||
hm.map_data = data
|
|
||||||
|
|
||||||
var body := StaticBody3D.new()
|
|
||||||
body.name = "TerrainBody"
|
|
||||||
add_child(body)
|
|
||||||
var col := CollisionShape3D.new()
|
|
||||||
col.name = "CollisionShape3D"
|
|
||||||
col.shape = hm
|
|
||||||
body.add_child(col)
|
|
||||||
# HeightMapShape3D samples are spaced 1 unit apart; scale the body so the
|
|
||||||
# collision grid covers the same `size` metres as the visual mesh.
|
|
||||||
body.scale = Vector3(step, 1.0, step)
|
|
||||||
|
|
||||||
built = true
|
built = true
|
||||||
ready_built.emit()
|
ready_built.emit()
|
||||||
|
|
||||||
## World-space ground height at (x, z).
|
func _make_textures() -> void:
|
||||||
func sample_height(x: float, z: float) -> float:
|
var fn := FastNoiseLite.new()
|
||||||
_ensure_noise()
|
fn.seed = noise_seed + 10
|
||||||
return _noise.get_noise_2d(x, z) * height_scale
|
fn.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH
|
||||||
|
fn.frequency = 0.008
|
||||||
|
fn.fractal_octaves = 5
|
||||||
|
noise_tex = NoiseTexture2D.new()
|
||||||
|
noise_tex.width = 512
|
||||||
|
noise_tex.height = 512
|
||||||
|
noise_tex.seamless = true
|
||||||
|
noise_tex.noise = fn
|
||||||
|
|
||||||
func _add_tri(st: SurfaceTool, a: Vector3, b: Vector3, c: Vector3, half: float, uv_scale: float) -> void:
|
var fn2 := FastNoiseLite.new()
|
||||||
st.set_uv(Vector2((a.x + half) * uv_scale, (a.z + half) * uv_scale))
|
fn2.seed = noise_seed + 11
|
||||||
st.add_vertex(a)
|
fn2.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH
|
||||||
st.set_uv(Vector2((b.x + half) * uv_scale, (b.z + half) * uv_scale))
|
fn2.frequency = 0.02
|
||||||
st.add_vertex(b)
|
fn2.fractal_octaves = 4
|
||||||
st.set_uv(Vector2((c.x + half) * uv_scale, (c.z + half) * uv_scale))
|
normal_tex = NoiseTexture2D.new()
|
||||||
st.add_vertex(c)
|
normal_tex.width = 512
|
||||||
|
normal_tex.height = 512
|
||||||
|
normal_tex.seamless = true
|
||||||
|
normal_tex.as_normal_map = true
|
||||||
|
normal_tex.bump_strength = 6.0
|
||||||
|
normal_tex.noise = fn2
|
||||||
|
|
||||||
|
func _build_mesh(n: int) -> void:
|
||||||
|
var verts := PackedVector3Array()
|
||||||
|
var uvs := PackedVector2Array()
|
||||||
|
verts.resize(n * n)
|
||||||
|
uvs.resize(n * n)
|
||||||
|
for iz in range(n):
|
||||||
|
for ix in range(n):
|
||||||
|
var i := iz * n + ix
|
||||||
|
verts[i] = Vector3(ix * step - half, _heights[i], iz * step - half)
|
||||||
|
uvs[i] = Vector2(float(ix) / resolution, float(iz) / resolution)
|
||||||
|
|
||||||
|
var idx := PackedInt32Array()
|
||||||
|
idx.resize(resolution * resolution * 6)
|
||||||
|
var k := 0
|
||||||
|
for iz in range(resolution):
|
||||||
|
for ix in range(resolution):
|
||||||
|
var i00 := iz * n + ix
|
||||||
|
var i10 := i00 + 1
|
||||||
|
var i01 := i00 + n
|
||||||
|
var i11 := i01 + 1
|
||||||
|
# Winding chosen so the top face is the front face in Godot.
|
||||||
|
idx[k] = i00; idx[k + 1] = i11; idx[k + 2] = i01
|
||||||
|
idx[k + 3] = i00; idx[k + 4] = i10; idx[k + 5] = i11
|
||||||
|
k += 6
|
||||||
|
|
||||||
|
var arrays := []
|
||||||
|
arrays.resize(Mesh.ARRAY_MAX)
|
||||||
|
arrays[Mesh.ARRAY_VERTEX] = verts
|
||||||
|
arrays[Mesh.ARRAY_NORMAL] = _normals
|
||||||
|
arrays[Mesh.ARRAY_TEX_UV] = uvs
|
||||||
|
arrays[Mesh.ARRAY_INDEX] = idx
|
||||||
|
var mesh := ArrayMesh.new()
|
||||||
|
mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
|
||||||
|
|
||||||
|
material = ShaderMaterial.new()
|
||||||
|
material.shader = preload("res://shaders/terrain.gdshader")
|
||||||
|
material.set_shader_parameter("noise_tex", noise_tex)
|
||||||
|
material.set_shader_parameter("normal_tex", normal_tex)
|
||||||
|
material.set_shader_parameter("water_level", water_level)
|
||||||
|
material.set_shader_parameter("snow_height", snow_height)
|
||||||
|
material.set_shader_parameter("grass_lush", grass_lush)
|
||||||
|
material.set_shader_parameter("grass_dry", grass_dry)
|
||||||
|
|
||||||
|
var mi := MeshInstance3D.new()
|
||||||
|
mi.name = "TerrainMesh"
|
||||||
|
mi.mesh = mesh
|
||||||
|
mi.material_override = material
|
||||||
|
add_child(mi)
|
||||||
|
|
||||||
|
func _build_collision(n: int) -> void:
|
||||||
|
var hm := HeightMapShape3D.new()
|
||||||
|
hm.map_width = n
|
||||||
|
hm.map_depth = n
|
||||||
|
hm.map_data = _heights
|
||||||
|
var body := StaticBody3D.new()
|
||||||
|
body.name = "TerrainBody"
|
||||||
|
add_child(body)
|
||||||
|
var col := CollisionShape3D.new()
|
||||||
|
col.shape = hm
|
||||||
|
body.add_child(col)
|
||||||
|
# HeightMapShape3D samples are 1 unit apart; scale to match the mesh.
|
||||||
|
body.scale = Vector3(step, 1.0, step)
|
||||||
|
|
||||||
|
## Invisible wall ringing the valley partway up the mountain slopes, plus four
|
||||||
|
## world-boundary planes at the chunk edges as a last line of defence.
|
||||||
|
func _build_boundary() -> void:
|
||||||
|
var body := StaticBody3D.new()
|
||||||
|
body.name = "Boundary"
|
||||||
|
add_child(body)
|
||||||
|
|
||||||
|
var segs := 128
|
||||||
|
var pts: Array[Vector3] = []
|
||||||
|
for i in range(segs):
|
||||||
|
var ang := TAU * i / segs
|
||||||
|
var dir := Vector3(cos(ang), 0.0, sin(ang))
|
||||||
|
# Solve for the radius whose rim distance equals the wall threshold.
|
||||||
|
var r := _wall_threshold(dir.x, dir.z) - _rim_offset(dir.x, dir.z)
|
||||||
|
pts.append(dir * r)
|
||||||
|
_wall_ring = pts
|
||||||
|
for i in range(segs):
|
||||||
|
var a := pts[i]
|
||||||
|
var b := pts[(i + 1) % segs]
|
||||||
|
var mid := (a + b) * 0.5
|
||||||
|
var length := a.distance_to(b) + 0.6
|
||||||
|
var box := BoxShape3D.new()
|
||||||
|
box.size = Vector3(length, 400.0, 1.5)
|
||||||
|
var cs := CollisionShape3D.new()
|
||||||
|
cs.shape = box
|
||||||
|
var along := (b - a).normalized()
|
||||||
|
var seg_basis := Basis(along, Vector3.UP, along.cross(Vector3.UP))
|
||||||
|
cs.transform = Transform3D(seg_basis, Vector3(mid.x, 100.0, mid.z))
|
||||||
|
body.add_child(cs)
|
||||||
|
|
||||||
|
var planes := [
|
||||||
|
Plane(Vector3.RIGHT, -half), Plane(Vector3.LEFT, -half),
|
||||||
|
Plane(Vector3.BACK, -half), Plane(Vector3.FORWARD, -half),
|
||||||
|
]
|
||||||
|
for p: Plane in planes:
|
||||||
|
var wb := WorldBoundaryShape3D.new()
|
||||||
|
wb.plane = p
|
||||||
|
var cs := CollisionShape3D.new()
|
||||||
|
cs.shape = wb
|
||||||
|
body.add_child(cs)
|
||||||
|
|
||||||
|
## Two water bodies: inland water (lake and ponds) as a disc clipped to the
|
||||||
|
## boundary ring, so it can't hang in the air past the sea cliff; and an ocean
|
||||||
|
## plane out to the horizon at sea level.
|
||||||
|
func _build_water() -> void:
|
||||||
|
var verts := PackedVector3Array([Vector3.ZERO])
|
||||||
|
for p in _wall_ring:
|
||||||
|
verts.append(p)
|
||||||
|
var count := verts.size()
|
||||||
|
var normals := PackedVector3Array()
|
||||||
|
var tangents := PackedFloat32Array()
|
||||||
|
var uvs := PackedVector2Array()
|
||||||
|
for v in verts:
|
||||||
|
normals.append(Vector3.UP)
|
||||||
|
tangents.append_array([1.0, 0.0, 0.0, 1.0])
|
||||||
|
uvs.append(Vector2(v.x, v.z) / size + Vector2(0.5, 0.5))
|
||||||
|
var idx := PackedInt32Array()
|
||||||
|
for i in range(1, count):
|
||||||
|
idx.append_array([0, i, i % (count - 1) + 1])
|
||||||
|
var arrays := []
|
||||||
|
arrays.resize(Mesh.ARRAY_MAX)
|
||||||
|
arrays[Mesh.ARRAY_VERTEX] = verts
|
||||||
|
arrays[Mesh.ARRAY_NORMAL] = normals
|
||||||
|
arrays[Mesh.ARRAY_TANGENT] = tangents
|
||||||
|
arrays[Mesh.ARRAY_TEX_UV] = uvs
|
||||||
|
arrays[Mesh.ARRAY_INDEX] = idx
|
||||||
|
var disc := ArrayMesh.new()
|
||||||
|
disc.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
|
||||||
|
_add_water("Water", disc, water_level, {})
|
||||||
|
|
||||||
|
var ocean := PlaneMesh.new()
|
||||||
|
ocean.size = Vector2(8000.0, 8000.0)
|
||||||
|
_add_water("Ocean", ocean, sea_level, {
|
||||||
|
"shallow_col": Color(0.16, 0.44, 0.50),
|
||||||
|
"deep_col": Color(0.02, 0.10, 0.20),
|
||||||
|
"clarity": 6.0,
|
||||||
|
})
|
||||||
|
|
||||||
|
func _add_water(label: String, mesh: Mesh, y: float, params: Dictionary) -> void:
|
||||||
|
var mat := ShaderMaterial.new()
|
||||||
|
mat.shader = preload("res://shaders/water.gdshader")
|
||||||
|
mat.set_shader_parameter("noise_tex", noise_tex)
|
||||||
|
mat.set_shader_parameter("normal_tex", normal_tex)
|
||||||
|
for k in params:
|
||||||
|
mat.set_shader_parameter(k, params[k])
|
||||||
|
var mi := MeshInstance3D.new()
|
||||||
|
mi.name = label
|
||||||
|
mi.mesh = mesh
|
||||||
|
mi.material_override = mat
|
||||||
|
mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
||||||
|
mi.position = Vector3(0.0, y, 0.0)
|
||||||
|
add_child(mi)
|
||||||
|
|
||||||
|
# --- queries ----------------------------------------------------------------
|
||||||
|
|
||||||
|
func in_bounds(x: float, z: float) -> bool:
|
||||||
|
return absf(x) < half - step and absf(z) < half - step
|
||||||
|
|
||||||
|
## True inside the walkable valley. A positive margin shrinks the area, a
|
||||||
|
## negative one extends it past the invisible wall.
|
||||||
|
func inside_boundary(x: float, z: float, margin: float = 0.0) -> bool:
|
||||||
|
_ensure_noise()
|
||||||
|
return _rim_d(x, z) < _wall_threshold(x, z) - margin
|
||||||
|
|
||||||
|
## How strongly (x, z) lies on the sea side of the rim (0..1).
|
||||||
|
func sea_weight(x: float, z: float) -> float:
|
||||||
|
_ensure_noise()
|
||||||
|
return _sea_w(x, z)
|
||||||
|
|
||||||
|
## Point on the coast along `angle_deg` (atan2(z, x) convention), at rim
|
||||||
|
## distance `past_edge` beyond the cliff edge (negative = inland of it).
|
||||||
|
func coast_point(angle_deg: float, past_edge: float) -> Vector2:
|
||||||
|
_ensure_noise()
|
||||||
|
var dir := Vector2(cos(deg_to_rad(angle_deg)), sin(deg_to_rad(angle_deg)))
|
||||||
|
var r := _cliff_d() + past_edge - _rim_offset(dir.x, dir.y)
|
||||||
|
return dir * r
|
||||||
|
|
||||||
|
## Surface height at (x, z), interpolated on the same triangles as the mesh.
|
||||||
|
func sample_height(x: float, z: float) -> float:
|
||||||
|
if _heights.is_empty():
|
||||||
|
return _height_fn(x, z)
|
||||||
|
var n := resolution + 1
|
||||||
|
var gx := clampf((x + half) / step, 0.0, resolution - 0.0001)
|
||||||
|
var gz := clampf((z + half) / step, 0.0, resolution - 0.0001)
|
||||||
|
var ix := int(gx)
|
||||||
|
var iz := int(gz)
|
||||||
|
var fx := gx - ix
|
||||||
|
var fz := gz - iz
|
||||||
|
var h00 := _heights[iz * n + ix]
|
||||||
|
var h10 := _heights[iz * n + ix + 1]
|
||||||
|
var h01 := _heights[(iz + 1) * n + ix]
|
||||||
|
var h11 := _heights[(iz + 1) * n + ix + 1]
|
||||||
|
if fx > fz:
|
||||||
|
return h00 + (h10 - h00) * fx + (h11 - h10) * fz
|
||||||
|
return h00 + (h11 - h01) * fx + (h01 - h00) * fz
|
||||||
|
|
||||||
|
## Smoothed surface normal at (x, z).
|
||||||
|
func sample_normal(x: float, z: float) -> Vector3:
|
||||||
|
if _normals.is_empty():
|
||||||
|
return Vector3.UP
|
||||||
|
var n := resolution + 1
|
||||||
|
var gx := clampf((x + half) / step, 0.0, resolution - 0.0001)
|
||||||
|
var gz := clampf((z + half) / step, 0.0, resolution - 0.0001)
|
||||||
|
var ix := int(gx)
|
||||||
|
var iz := int(gz)
|
||||||
|
var fx := gx - ix
|
||||||
|
var fz := gz - iz
|
||||||
|
var a := _normals[iz * n + ix].lerp(_normals[iz * n + ix + 1], fx)
|
||||||
|
var b := _normals[(iz + 1) * n + ix].lerp(_normals[(iz + 1) * n + ix + 1], fx)
|
||||||
|
return a.lerp(b, fz).normalized()
|
||||||
|
|
||||||
|
## Nearest dry, gentle spot to `near` (spiral search), for spawning.
|
||||||
|
func find_spawn(near: Vector2) -> Vector3:
|
||||||
|
for ring in range(0, 30):
|
||||||
|
var r := ring * 2.0
|
||||||
|
var count := maxi(1, ring * 6)
|
||||||
|
for i in range(count):
|
||||||
|
var ang := TAU * i / count
|
||||||
|
var x := near.x + cos(ang) * r
|
||||||
|
var z := near.y + sin(ang) * r
|
||||||
|
var h := sample_height(x, z)
|
||||||
|
if h > water_level + 1.2 and sample_normal(x, z).y > 0.93 and inside_boundary(x, z, 15.0):
|
||||||
|
return Vector3(x, h, z)
|
||||||
|
return Vector3(near.x, sample_height(near.x, near.y), near.y)
|
||||||
|
|||||||
1
scripts/world/terrain.gd.uid
Normal file
1
scripts/world/terrain.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://bd438cmtac3r
|
||||||
@@ -1,31 +1,116 @@
|
|||||||
extends Node3D
|
extends Node3D
|
||||||
## World controller — M0.
|
## World controller.
|
||||||
##
|
##
|
||||||
## Owns spawn ordering so the player never falls through ungenerated ground:
|
## Owns spawn ordering so the player never falls through ungenerated ground:
|
||||||
## waits for the terrain to finish building, then drops the player onto the
|
## waits for the terrain, places the scenery, then drops the player onto a dry,
|
||||||
## surface height and lets gravity settle them. Also tells the props where the
|
## flat spot facing the lake. Also aims the sun, and respawns the player if they
|
||||||
## ground is so they sit on it instead of floating.
|
## somehow leave the map or fall below it (a safety net behind the mountain rim
|
||||||
|
## and invisible wall in terrain.gd).
|
||||||
|
##
|
||||||
|
## Dev hook: `godot --path . -- --shot=/abs/prefix` renders a few fixed views to
|
||||||
|
## <prefix>_<view>.png and quits. Handy for checking visual changes.
|
||||||
|
|
||||||
|
@export var sun_elevation_deg: float = 34.0
|
||||||
|
@export var sun_azimuth_deg: float = 40.0 ## 0 = behind the spawn view, + = to its right
|
||||||
|
|
||||||
@onready var terrain: Node3D = $Terrain
|
@onready var terrain: Node3D = $Terrain
|
||||||
@onready var props: Node3D = $Props
|
@onready var props: Node3D = $Props
|
||||||
@onready var player: CharacterBody3D = $Player
|
@onready var player: CharacterBody3D = $Player
|
||||||
|
@onready var sun: DirectionalLight3D = $Sun
|
||||||
|
|
||||||
|
var _spawn := Vector3.ZERO
|
||||||
|
var _spawn_yaw := 0.0
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
# Freeze the player until the ground exists.
|
_aim_sun()
|
||||||
player.set_physics_process(false)
|
player.set_physics_process(false)
|
||||||
|
set_physics_process(false)
|
||||||
if terrain.built:
|
if terrain.built:
|
||||||
_on_terrain_ready()
|
_on_terrain_ready()
|
||||||
else:
|
else:
|
||||||
terrain.ready_built.connect(_on_terrain_ready)
|
terrain.ready_built.connect(_on_terrain_ready)
|
||||||
|
|
||||||
func _on_terrain_ready() -> void:
|
func _aim_sun() -> void:
|
||||||
# Let props place themselves on the surface.
|
var el := deg_to_rad(sun_elevation_deg)
|
||||||
if props.has_method("place_on"):
|
var az := deg_to_rad(sun_azimuth_deg)
|
||||||
props.place_on(terrain)
|
var to_sun := Vector3(sin(az) * cos(el), sin(el), cos(az) * cos(el))
|
||||||
|
sun.global_basis = Basis.looking_at(-to_sun, Vector3.UP)
|
||||||
|
|
||||||
# Spawn the player on the ground at (0,0), a little above so they settle down
|
func _on_terrain_ready() -> void:
|
||||||
# onto the collision rather than starting embedded in it.
|
var t0 := Time.get_ticks_msec()
|
||||||
var h: float = terrain.sample_height(0.0, 0.0)
|
_spawn = terrain.find_spawn(Vector2.ZERO)
|
||||||
player.global_position = Vector3(0.0, h + 2.0, 0.0)
|
var lake: Vector2 = terrain.lake_center
|
||||||
player.velocity = Vector3.ZERO
|
var to_lake := lake - Vector2(_spawn.x, _spawn.z)
|
||||||
|
_spawn_yaw = atan2(-to_lake.x, -to_lake.y)
|
||||||
|
player.water_level = terrain.water_level
|
||||||
|
props.place_on(terrain, player, _spawn)
|
||||||
|
print("[world] scenery placed in %d ms" % (Time.get_ticks_msec() - t0))
|
||||||
|
_respawn()
|
||||||
player.set_physics_process(true)
|
player.set_physics_process(true)
|
||||||
|
set_physics_process(true)
|
||||||
|
_maybe_capture()
|
||||||
|
|
||||||
|
func _respawn() -> void:
|
||||||
|
player.global_position = _spawn + Vector3(0.0, 1.5, 0.0)
|
||||||
|
player.velocity = Vector3.ZERO
|
||||||
|
player.rotation = Vector3(0.0, _spawn_yaw, 0.0)
|
||||||
|
player.get_node("Camera3D").rotation.x = 0.0
|
||||||
|
|
||||||
|
func _physics_process(_delta: float) -> void:
|
||||||
|
var p := player.global_position
|
||||||
|
var half: float = terrain.size * 0.5
|
||||||
|
if p.y < terrain.min_height - 30.0 or absf(p.x) > half or absf(p.z) > half \
|
||||||
|
or not terrain.inside_boundary(p.x, p.z, -4.0):
|
||||||
|
push_warning("[world] player left the map at %s; respawning" % p)
|
||||||
|
_respawn()
|
||||||
|
|
||||||
|
# --- dev screenshot hook ----------------------------------------------------
|
||||||
|
|
||||||
|
func _maybe_capture() -> void:
|
||||||
|
var prefix := ""
|
||||||
|
for a in OS.get_cmdline_user_args():
|
||||||
|
if a.begins_with("--shot="):
|
||||||
|
prefix = a.trim_prefix("--shot=")
|
||||||
|
if prefix.is_empty():
|
||||||
|
return
|
||||||
|
var lake: Vector2 = terrain.lake_center
|
||||||
|
# [name, eye position (y = height above ground), look-at point] ; null = spawn view
|
||||||
|
var views := [
|
||||||
|
["spawn", null, null],
|
||||||
|
["aerial", Vector3(0, 110, 150), Vector3(0, 0, -20)],
|
||||||
|
["lake", Vector3(lake.x - 30.0, 3.0, lake.y + 22.0), Vector3(lake.x, -2.0, lake.y)],
|
||||||
|
["forest", Vector3(-40, 1.7, 30), Vector3(-70, 4.0, 10)],
|
||||||
|
["rim", Vector3(0, 2.0, 70), Vector3(0, 25.0, 125)],
|
||||||
|
]
|
||||||
|
# Sea-cliff views: from the cliff top out to sea, and from the water back at the cliff.
|
||||||
|
var sa: float = terrain.sea_angle_deg
|
||||||
|
var top: Vector2 = terrain.coast_point(sa + 12.0, -6.0)
|
||||||
|
var out: Vector2 = terrain.coast_point(sa + 12.0, 200.0)
|
||||||
|
views.append(["seacliff_top", Vector3(top.x, 1.7, top.y), Vector3(out.x, terrain.sea_level - 6.0, out.y)])
|
||||||
|
var boat: Vector2 = terrain.coast_point(sa - 6.0, 38.0)
|
||||||
|
var face: Vector2 = terrain.coast_point(sa + 4.0, 0.0)
|
||||||
|
var boat_eye := Vector3(boat.x, terrain.sea_level + 3.0 - terrain.sample_height(boat.x, boat.y), boat.y)
|
||||||
|
views.append(["seacliff_from_sea", boat_eye, Vector3(face.x, terrain.sea_level + 8.0, face.y)])
|
||||||
|
# Along the cliff edge into the western headland (where the old pit was).
|
||||||
|
var walk: Vector2 = terrain.coast_point(sa - 20.0, -8.0)
|
||||||
|
var head: Vector2 = terrain.coast_point(sa - 52.0, 0.0)
|
||||||
|
views.append(["headland", Vector3(walk.x, 1.7, walk.y), Vector3(head.x, 2.0, head.y)])
|
||||||
|
await get_tree().create_timer(4.0).timeout
|
||||||
|
set_physics_process(false) # views may sit outside the walkable area
|
||||||
|
var cam: Camera3D = player.get_node("Camera3D")
|
||||||
|
for v in views:
|
||||||
|
if v[1] != null:
|
||||||
|
player.set_physics_process(false)
|
||||||
|
var eye: Vector3 = v[1]
|
||||||
|
eye.y += terrain.sample_height(eye.x, eye.z)
|
||||||
|
var dir: Vector3 = (v[2] as Vector3) - eye
|
||||||
|
player.global_position = eye - Vector3(0.0, cam.position.y, 0.0)
|
||||||
|
player.rotation = Vector3(0.0, atan2(-dir.x, -dir.z), 0.0)
|
||||||
|
cam.rotation.x = atan2(dir.y, Vector2(dir.x, dir.z).length())
|
||||||
|
props._update_grass(100000)
|
||||||
|
await get_tree().create_timer(1.0).timeout
|
||||||
|
await RenderingServer.frame_post_draw
|
||||||
|
var path := "%s_%s.png" % [prefix, v[0]]
|
||||||
|
get_viewport().get_texture().get_image().save_png(path)
|
||||||
|
print("[world] saved ", path)
|
||||||
|
get_tree().quit()
|
||||||
|
|||||||
1
scripts/world/world.gd.uid
Normal file
1
scripts/world/world.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://l8eo7vy8f3np
|
||||||
23
shaders/common.gdshaderinc
Normal file
23
shaders/common.gdshaderinc
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
// Shared helpers for the world shaders.
|
||||||
|
// Declares `noise_tex` (a seamless NoiseTexture2D set from terrain.gd) so every
|
||||||
|
// shader samples the same noise, which keeps grass, terrain and rock colour
|
||||||
|
// variation lined up with each other.
|
||||||
|
|
||||||
|
uniform sampler2D noise_tex : filter_linear_mipmap, repeat_enable;
|
||||||
|
|
||||||
|
float nz(vec2 p) {
|
||||||
|
return texture(noise_tex, p).r;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Triplanar noise: samples along all three axes so steep faces (cliffs, rock
|
||||||
|
// sides) don't show the stretched streaks a top-down projection would give.
|
||||||
|
float tri_nz(vec3 p, vec3 n, float s) {
|
||||||
|
vec3 w = pow(abs(n), vec3(4.0));
|
||||||
|
w /= (w.x + w.y + w.z);
|
||||||
|
return nz(p.zy * s) * w.x + nz(p.xz * s) * w.y + nz(p.xy * s) * w.z;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vertex colours are authored as sRGB; lighting needs linear.
|
||||||
|
vec3 to_linear(vec3 c) {
|
||||||
|
return pow(c, vec3(2.2));
|
||||||
|
}
|
||||||
1
shaders/common.gdshaderinc.uid
Normal file
1
shaders/common.gdshaderinc.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://dir1djw6ljm4w
|
||||||
34
shaders/foliage.gdshader
Normal file
34
shaders/foliage.gdshader
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
shader_type spatial;
|
||||||
|
render_mode cull_back, diffuse_burley;
|
||||||
|
|
||||||
|
// Tree canopies and bushes. Colour comes from per-face vertex colours (set by
|
||||||
|
// proc_mesh.gd), tinted per instance so no two trees are quite the same.
|
||||||
|
// Wind: slow whole-crown sway growing with height, plus a small leaf flutter.
|
||||||
|
|
||||||
|
#include "res://shaders/common.gdshaderinc"
|
||||||
|
|
||||||
|
uniform float wind_strength = 0.04;
|
||||||
|
uniform float flutter = 0.03;
|
||||||
|
|
||||||
|
varying vec3 inst_pos;
|
||||||
|
|
||||||
|
void vertex() {
|
||||||
|
inst_pos = MODEL_MATRIX[3].xyz;
|
||||||
|
float h = max(VERTEX.y - 1.2, 0.0);
|
||||||
|
float phase = dot(inst_pos.xz, vec2(0.13, 0.17));
|
||||||
|
float gust = nz(inst_pos.xz * 0.012 + vec2(TIME * 0.02, TIME * 0.013));
|
||||||
|
vec3 sway = vec3(sin(TIME * 0.9 + phase), 0.0, cos(TIME * 0.7 + phase * 1.3) * 0.6);
|
||||||
|
sway *= wind_strength * h * (0.4 + gust);
|
||||||
|
sway += vec3(sin(TIME * 3.7 + dot(VERTEX, vec3(3.1, 1.7, 2.3)) + phase)) * flutter * clamp(VERTEX.y, 0.0, 1.0);
|
||||||
|
VERTEX += (inverse(MODEL_MATRIX) * vec4(sway, 0.0)).xyz;
|
||||||
|
}
|
||||||
|
|
||||||
|
void fragment() {
|
||||||
|
float v = nz(inst_pos.xz * 0.045);
|
||||||
|
vec3 tint = mix(vec3(0.88, 1.0, 0.85), vec3(1.12, 1.04, 0.78), v);
|
||||||
|
vec3 col = to_linear(COLOR.rgb) * tint;
|
||||||
|
ALBEDO = col;
|
||||||
|
ROUGHNESS = 0.85;
|
||||||
|
SPECULAR = 0.3;
|
||||||
|
BACKLIGHT = col * 0.5; // light glowing through leaves when backlit
|
||||||
|
}
|
||||||
1
shaders/foliage.gdshader.uid
Normal file
1
shaders/foliage.gdshader.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://di7bbdh70ax4b
|
||||||
45
shaders/grass.gdshader
Normal file
45
shaders/grass.gdshader
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
shader_type spatial;
|
||||||
|
render_mode cull_disabled, diffuse_burley;
|
||||||
|
|
||||||
|
// Grass clumps (MultiMesh). UV.y runs 0 at the root to 1 at the tip and drives
|
||||||
|
// both wind bend and the root-to-tip colour gradient. Base colour uses the same
|
||||||
|
// noise lookups as terrain.gdshader so the blades blend into the ground beneath.
|
||||||
|
|
||||||
|
#include "res://shaders/common.gdshaderinc"
|
||||||
|
|
||||||
|
uniform vec3 grass_lush : source_color = vec3(0.25, 0.40, 0.15);
|
||||||
|
uniform vec3 grass_dry : source_color = vec3(0.48, 0.50, 0.22);
|
||||||
|
uniform vec3 tip_tint : source_color = vec3(0.78, 0.88, 0.50);
|
||||||
|
uniform float wind_strength = 0.2;
|
||||||
|
uniform vec2 wind_dir = vec2(1.0, 0.35);
|
||||||
|
|
||||||
|
varying vec3 inst_pos;
|
||||||
|
varying float hfac;
|
||||||
|
|
||||||
|
void vertex() {
|
||||||
|
inst_pos = MODEL_MATRIX[3].xyz;
|
||||||
|
hfac = UV.y;
|
||||||
|
vec3 wp = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz;
|
||||||
|
vec2 wd = normalize(wind_dir);
|
||||||
|
float gust = nz(wp.xz * 0.015 - TIME * 0.05 * wd);
|
||||||
|
float sway = sin(TIME * 1.8 + wp.x * 0.35 + wp.z * 0.25) * 0.35 + gust * 1.3 - 0.4;
|
||||||
|
float k = wind_strength * hfac * hfac;
|
||||||
|
vec3 off = vec3(wd.x * sway * k, -abs(sway) * k * 0.3, wd.y * sway * k);
|
||||||
|
VERTEX += (inverse(MODEL_MATRIX) * vec4(off, 0.0)).xyz;
|
||||||
|
}
|
||||||
|
|
||||||
|
void fragment() {
|
||||||
|
float macro = nz(inst_pos.xz * 0.0035);
|
||||||
|
float mid = nz(inst_pos.xz * 0.021 + 0.37);
|
||||||
|
vec3 base = mix(grass_lush, grass_dry, smoothstep(0.48, 0.78, macro + (mid - 0.5) * 0.35));
|
||||||
|
base *= 0.82 + 0.36 * nz(inst_pos.xz * 0.17);
|
||||||
|
vec3 col = base * mix(0.55, 1.0, hfac); // darker at the root
|
||||||
|
col = mix(col, col * tip_tint * 1.3, smoothstep(0.6, 1.0, hfac) * 0.3);
|
||||||
|
ALBEDO = col;
|
||||||
|
ROUGHNESS = 0.9;
|
||||||
|
SPECULAR = 0.2;
|
||||||
|
BACKLIGHT = vec3(0.35, 0.4, 0.15) * hfac;
|
||||||
|
// Light blades as if they faced up (same as the ground), so a field reads as
|
||||||
|
// one soft surface instead of thousands of flickering cards.
|
||||||
|
NORMAL = normalize((VIEW_MATRIX * vec4(0.0, 1.0, 0.0, 0.0)).xyz);
|
||||||
|
}
|
||||||
1
shaders/grass.gdshader.uid
Normal file
1
shaders/grass.gdshader.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://46hvkgp2jvps
|
||||||
40
shaders/rock.gdshader
Normal file
40
shaders/rock.gdshader
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
shader_type spatial;
|
||||||
|
render_mode cull_back, diffuse_burley;
|
||||||
|
|
||||||
|
// Faceted low-poly rocks: triplanar grain, per-rock tint, moss on upward faces
|
||||||
|
// (on some rocks more than others), snow on top at altitude.
|
||||||
|
|
||||||
|
#include "res://shaders/common.gdshaderinc"
|
||||||
|
|
||||||
|
uniform vec3 moss_col : source_color = vec3(0.27, 0.38, 0.15);
|
||||||
|
uniform vec3 snow_col : source_color = vec3(0.93, 0.95, 0.98);
|
||||||
|
uniform float snow_height = 40.0;
|
||||||
|
|
||||||
|
varying vec3 w_pos;
|
||||||
|
varying vec3 w_nrm;
|
||||||
|
varying vec3 inst_pos;
|
||||||
|
|
||||||
|
void vertex() {
|
||||||
|
w_pos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz;
|
||||||
|
w_nrm = normalize((MODEL_MATRIX * vec4(NORMAL, 0.0)).xyz);
|
||||||
|
inst_pos = MODEL_MATRIX[3].xyz;
|
||||||
|
}
|
||||||
|
|
||||||
|
void fragment() {
|
||||||
|
float a = tri_nz(w_pos, w_nrm, 0.3);
|
||||||
|
float b = tri_nz(w_pos, w_nrm, 1.4);
|
||||||
|
vec3 base = to_linear(COLOR.rgb) * (0.7 + 0.6 * a) * (0.88 + 0.24 * b);
|
||||||
|
base *= mix(vec3(1.04, 1.0, 0.94), vec3(0.94, 0.97, 1.03), nz(inst_pos.xz * 0.07));
|
||||||
|
|
||||||
|
float mossy = smoothstep(0.3, 0.7, nz(inst_pos.xz * 0.021 + 0.5));
|
||||||
|
float moss = smoothstep(0.45, 0.8, w_nrm.y + (a - 0.5) * 0.7) * mossy;
|
||||||
|
vec3 col = mix(base, moss_col * (0.75 + 0.5 * b), moss);
|
||||||
|
|
||||||
|
float snow = smoothstep(snow_height - 3.0, snow_height + 3.0, w_pos.y + (a - 0.5) * 10.0);
|
||||||
|
snow *= smoothstep(0.35, 0.7, w_nrm.y);
|
||||||
|
col = mix(col, snow_col, snow);
|
||||||
|
|
||||||
|
ALBEDO = col;
|
||||||
|
ROUGHNESS = mix(mix(0.8, 0.95, moss), 0.6, snow);
|
||||||
|
SPECULAR = 0.35;
|
||||||
|
}
|
||||||
1
shaders/rock.gdshader.uid
Normal file
1
shaders/rock.gdshader.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://dnqc6nl4l50ih
|
||||||
41
shaders/sky.gdshader
Normal file
41
shaders/sky.gdshader
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
shader_type sky;
|
||||||
|
|
||||||
|
// Gradient sky + sun disc/halo + a static cloud layer projected onto a dome.
|
||||||
|
// Deliberately no TIME: a time-varying sky forces Godot to rebuild the ambient
|
||||||
|
// radiance map every frame.
|
||||||
|
|
||||||
|
uniform vec3 zenith_col : source_color = vec3(0.24, 0.44, 0.72);
|
||||||
|
uniform vec3 horizon_col : source_color = vec3(0.72, 0.79, 0.85);
|
||||||
|
uniform vec3 ground_col : source_color = vec3(0.30, 0.33, 0.32);
|
||||||
|
uniform vec3 sun_tint : source_color = vec3(1.0, 0.86, 0.62);
|
||||||
|
uniform sampler2D cloud_tex : filter_linear_mipmap, repeat_enable;
|
||||||
|
uniform float cloud_cover = 0.5;
|
||||||
|
uniform float energy = 1.0;
|
||||||
|
|
||||||
|
void sky() {
|
||||||
|
vec3 d = EYEDIR;
|
||||||
|
float h = d.y;
|
||||||
|
vec3 col = mix(horizon_col, zenith_col, pow(smoothstep(0.0, 0.6, h), 0.7));
|
||||||
|
col = mix(col, ground_col, smoothstep(0.0, -0.12, h));
|
||||||
|
|
||||||
|
float sd = 0.0;
|
||||||
|
if (LIGHT0_ENABLED) {
|
||||||
|
sd = max(dot(d, LIGHT0_DIRECTION), 0.0);
|
||||||
|
col += sun_tint * pow(sd, 6.0) * 0.35 * (1.0 - smoothstep(0.0, 0.5, h));
|
||||||
|
col += sun_tint * pow(sd, 64.0) * 0.6;
|
||||||
|
col += LIGHT0_COLOR * smoothstep(0.9994, 0.9997, sd) * 20.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (h > 0.0) {
|
||||||
|
vec2 uv = d.xz / (h + 0.12);
|
||||||
|
float c = texture(cloud_tex, uv * 0.08).r * 0.65 + texture(cloud_tex, uv * 0.23 + 0.31).r * 0.35;
|
||||||
|
float cov = smoothstep(1.0 - cloud_cover, 1.0 - cloud_cover + 0.22, c);
|
||||||
|
cov *= smoothstep(0.0, 0.18, h);
|
||||||
|
float shade = smoothstep(0.35, 1.0, c);
|
||||||
|
vec3 cloud = mix(vec3(1.0), vec3(0.62, 0.66, 0.72), shade * 0.8);
|
||||||
|
cloud += sun_tint * pow(sd, 4.0) * 0.5;
|
||||||
|
col = mix(col, cloud, cov * 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
COLOR = col * energy;
|
||||||
|
}
|
||||||
1
shaders/sky.gdshader.uid
Normal file
1
shaders/sky.gdshader.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://c5obebod41tnk
|
||||||
74
shaders/terrain.gdshader
Normal file
74
shaders/terrain.gdshader
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
shader_type spatial;
|
||||||
|
render_mode cull_back, diffuse_burley, specular_schlick_ggx;
|
||||||
|
|
||||||
|
// Procedural terrain material: no textures to author. Colour comes from world
|
||||||
|
// position + slope + height, layered at several noise scales so the ground
|
||||||
|
// doesn't look like one flat colour from any distance.
|
||||||
|
|
||||||
|
#include "res://shaders/common.gdshaderinc"
|
||||||
|
|
||||||
|
uniform sampler2D normal_tex : hint_normal, filter_linear_mipmap, repeat_enable;
|
||||||
|
|
||||||
|
uniform vec3 grass_lush : source_color = vec3(0.25, 0.40, 0.15);
|
||||||
|
uniform vec3 grass_dry : source_color = vec3(0.48, 0.50, 0.22);
|
||||||
|
uniform vec3 dirt_col : source_color = vec3(0.34, 0.26, 0.17);
|
||||||
|
uniform vec3 rock_light : source_color = vec3(0.50, 0.48, 0.45);
|
||||||
|
uniform vec3 rock_dark : source_color = vec3(0.26, 0.26, 0.27);
|
||||||
|
uniform vec3 sand_col : source_color = vec3(0.62, 0.56, 0.42);
|
||||||
|
uniform vec3 snow_col : source_color = vec3(0.93, 0.95, 0.98);
|
||||||
|
uniform float water_level = -2.6;
|
||||||
|
uniform float snow_height = 40.0;
|
||||||
|
|
||||||
|
varying vec3 w_pos;
|
||||||
|
varying vec3 w_nrm;
|
||||||
|
|
||||||
|
void vertex() {
|
||||||
|
w_pos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz;
|
||||||
|
w_nrm = normalize((MODEL_MATRIX * vec4(NORMAL, 0.0)).xyz);
|
||||||
|
// The terrain mesh has no authored tangents; build a frame for the detail
|
||||||
|
// normal map. (Terrain normals are never parallel to Z, so this is safe.)
|
||||||
|
TANGENT = normalize(cross(NORMAL, vec3(0.0, 0.0, 1.0)));
|
||||||
|
BINORMAL = normalize(cross(TANGENT, NORMAL));
|
||||||
|
}
|
||||||
|
|
||||||
|
void fragment() {
|
||||||
|
float slope = 1.0 - clamp(w_nrm.y, 0.0, 1.0);
|
||||||
|
float macro = nz(w_pos.xz * 0.0035);
|
||||||
|
float mid = nz(w_pos.xz * 0.021 + 0.37);
|
||||||
|
float fine = nz(w_pos.xz * 0.17);
|
||||||
|
float micro = nz(w_pos.xz * 0.9);
|
||||||
|
|
||||||
|
// Grass: lush/dry regions, then mottling at two finer scales.
|
||||||
|
vec3 col = mix(grass_lush, grass_dry, smoothstep(0.48, 0.78, macro + (mid - 0.5) * 0.35));
|
||||||
|
col *= 0.82 + 0.36 * fine;
|
||||||
|
col *= 0.92 + 0.16 * micro;
|
||||||
|
|
||||||
|
// Bare dirt patches.
|
||||||
|
float dirt_m = smoothstep(0.64, 0.74, mid + (fine - 0.5) * 0.35);
|
||||||
|
col = mix(col, dirt_col * (0.8 + 0.4 * micro), dirt_m);
|
||||||
|
|
||||||
|
// Sandy shoreline, darker mud below the waterline.
|
||||||
|
float sand_m = 1.0 - smoothstep(water_level + 0.3, water_level + 1.2 + fine * 0.6, w_pos.y);
|
||||||
|
col = mix(col, sand_col * (0.85 + 0.3 * micro), sand_m);
|
||||||
|
col = mix(col, dirt_col * 0.6, 1.0 - smoothstep(water_level - 1.8, water_level - 0.2, w_pos.y));
|
||||||
|
|
||||||
|
// Rock on steep ground, with horizontal strata so cliffs read as layered stone.
|
||||||
|
float tn = tri_nz(w_pos, w_nrm, 0.08);
|
||||||
|
float tn2 = tri_nz(w_pos, w_nrm, 0.45);
|
||||||
|
float strata = sin(w_pos.y * 2.2 + tn * 9.0) * 0.5 + 0.5;
|
||||||
|
vec3 rock = mix(rock_dark, rock_light, clamp(tn * 0.9 + strata * 0.35 - 0.1, 0.0, 1.0));
|
||||||
|
rock *= 0.82 + 0.36 * tn2;
|
||||||
|
float rock_m = smoothstep(0.28, 0.42, slope + (fine - 0.5) * 0.18);
|
||||||
|
col = mix(col, rock, rock_m);
|
||||||
|
|
||||||
|
// Snow on the flatter parts of the peaks.
|
||||||
|
float snow_m = smoothstep(snow_height - 3.0, snow_height + 3.0, w_pos.y + (mid - 0.5) * 14.0);
|
||||||
|
snow_m *= 1.0 - smoothstep(0.5, 0.7, slope);
|
||||||
|
col = mix(col, snow_col * (0.92 + 0.08 * fine), snow_m);
|
||||||
|
|
||||||
|
ALBEDO = col;
|
||||||
|
ROUGHNESS = mix(mix(0.95, 0.82, rock_m), 0.6, snow_m);
|
||||||
|
SPECULAR = 0.3;
|
||||||
|
NORMAL_MAP = texture(normal_tex, w_pos.xz * 0.35).rgb;
|
||||||
|
NORMAL_MAP_DEPTH = mix(0.5, 1.4, rock_m) * (1.0 - snow_m * 0.7);
|
||||||
|
}
|
||||||
1
shaders/terrain.gdshader.uid
Normal file
1
shaders/terrain.gdshader.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://cvhdrgrkmjy8i
|
||||||
47
shaders/water.gdshader
Normal file
47
shaders/water.gdshader
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
shader_type spatial;
|
||||||
|
render_mode blend_mix, cull_disabled, diffuse_burley, specular_schlick_ggx;
|
||||||
|
|
||||||
|
// Lake water: two scrolling normal maps for ripples, colour and opacity from
|
||||||
|
// the depth of water under each pixel (clear shallows, dark deep water), and
|
||||||
|
// foam where the surface meets the shore.
|
||||||
|
|
||||||
|
#include "res://shaders/common.gdshaderinc"
|
||||||
|
|
||||||
|
uniform sampler2D DEPTH_TEXTURE : hint_depth_texture, filter_linear_mipmap;
|
||||||
|
uniform sampler2D normal_tex : hint_normal, filter_linear_mipmap, repeat_enable;
|
||||||
|
uniform vec3 shallow_col : source_color = vec3(0.22, 0.44, 0.42);
|
||||||
|
uniform vec3 deep_col : source_color = vec3(0.03, 0.12, 0.17);
|
||||||
|
uniform float clarity = 2.5;
|
||||||
|
|
||||||
|
varying vec3 w_pos;
|
||||||
|
|
||||||
|
void vertex() {
|
||||||
|
w_pos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz;
|
||||||
|
}
|
||||||
|
|
||||||
|
void fragment() {
|
||||||
|
vec2 uv = w_pos.xz;
|
||||||
|
vec3 n1 = texture(normal_tex, uv * 0.045 + TIME * vec2(0.012, 0.008)).rgb;
|
||||||
|
vec3 n2 = texture(normal_tex, uv * 0.11 + TIME * vec2(-0.010, 0.017)).rgb;
|
||||||
|
NORMAL_MAP = mix(n1, n2, 0.5);
|
||||||
|
NORMAL_MAP_DEPTH = 0.7;
|
||||||
|
|
||||||
|
// Thickness of water between the surface and whatever is behind it.
|
||||||
|
// (Godot 4.3+ uses reversed-Z: raw depth is the NDC z.)
|
||||||
|
float depth = texture(DEPTH_TEXTURE, SCREEN_UV).r;
|
||||||
|
vec4 behind = INV_PROJECTION_MATRIX * vec4(SCREEN_UV * 2.0 - 1.0, depth, 1.0);
|
||||||
|
behind.xyz /= behind.w;
|
||||||
|
float thick = max(VERTEX.z - behind.z, 0.0);
|
||||||
|
|
||||||
|
float t = 1.0 - exp(-thick / clarity);
|
||||||
|
vec3 col = mix(shallow_col, deep_col, t);
|
||||||
|
float foam_n = nz(uv * 0.6 + TIME * 0.02);
|
||||||
|
float foam = (1.0 - smoothstep(0.0, 0.25 + foam_n * 0.35, thick)) * 0.8;
|
||||||
|
col = mix(col, vec3(0.88, 0.93, 0.93), foam);
|
||||||
|
|
||||||
|
ALBEDO = col;
|
||||||
|
ALPHA = clamp(mix(0.3, 0.93, t) + foam, 0.0, 1.0);
|
||||||
|
ROUGHNESS = 0.04;
|
||||||
|
SPECULAR = 0.6;
|
||||||
|
METALLIC = 0.0;
|
||||||
|
}
|
||||||
1
shaders/water.gdshader.uid
Normal file
1
shaders/water.gdshader.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://cuumgc10rnbll
|
||||||
57
tests/test_boundary.gd
Normal file
57
tests/test_boundary.gd
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## Boundary test: tries to escape the valley in several directions by walking
|
||||||
|
## and spamming jump at the mountain rim, and fails if the player ever gets past
|
||||||
|
## the invisible wall, falls off the map, or ends up stuck below the waterline
|
||||||
|
## (in a pit near the edge).
|
||||||
|
##
|
||||||
|
## Run: godot --headless --path . --fixed-fps 30 --script res://tests/test_boundary.gd
|
||||||
|
|
||||||
|
const DIRECTIONS := 24
|
||||||
|
const SECONDS_PER_TRY := 25.0
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
var world: Node3D = load("res://scenes/world.tscn").instantiate()
|
||||||
|
root.add_child(world)
|
||||||
|
_run.call_deferred(world)
|
||||||
|
|
||||||
|
func _run(world: Node3D) -> void:
|
||||||
|
for _i in range(10):
|
||||||
|
await physics_frame
|
||||||
|
var terrain: Node3D = world.get_node("Terrain")
|
||||||
|
var player: CharacterBody3D = world.get_node("Player")
|
||||||
|
var failures := 0
|
||||||
|
var tick := 1.0 / Engine.physics_ticks_per_second
|
||||||
|
for i in range(DIRECTIONS):
|
||||||
|
var ang := TAU * i / DIRECTIONS
|
||||||
|
var out := Vector3(cos(ang), 0.0, sin(ang))
|
||||||
|
var start: Vector3 = out * (terrain.play_radius - 12.0)
|
||||||
|
start.y = terrain.sample_height(start.x, start.z) + 1.0
|
||||||
|
player.global_position = start
|
||||||
|
player.velocity = Vector3.ZERO
|
||||||
|
player.rotation = Vector3(0.0, atan2(-out.x, -out.z), 0.0)
|
||||||
|
Input.action_press("move_forward")
|
||||||
|
var escaped := false
|
||||||
|
var max_d := 0.0
|
||||||
|
var frames := int(SECONDS_PER_TRY / tick)
|
||||||
|
for f in range(frames):
|
||||||
|
if f % 12 == 0:
|
||||||
|
Input.action_press("jump")
|
||||||
|
elif f % 12 == 1:
|
||||||
|
Input.action_release("jump")
|
||||||
|
await physics_frame
|
||||||
|
var p := player.global_position
|
||||||
|
max_d = maxf(max_d, Vector2(p.x, p.z).length())
|
||||||
|
if not terrain.inside_boundary(p.x, p.z, -1.5) or p.y < terrain.min_height - 5.0:
|
||||||
|
escaped = true
|
||||||
|
break
|
||||||
|
Input.action_release("move_forward")
|
||||||
|
Input.action_release("jump")
|
||||||
|
var p := player.global_position
|
||||||
|
if not escaped and p.y < terrain.water_level - 0.5:
|
||||||
|
escaped = true # stuck in a pit below the waterline
|
||||||
|
print("dir %2d (%3d deg): %s max radius %.1f final (%.1f, %.1f, %.1f)" % [
|
||||||
|
i, int(rad_to_deg(ang)), "FAILED" if escaped else "held", max_d, p.x, p.y, p.z])
|
||||||
|
if escaped:
|
||||||
|
failures += 1
|
||||||
|
print("RESULT: %s (%d/%d directions held)" % ["PASS" if failures == 0 else "FAIL", DIRECTIONS - failures, DIRECTIONS])
|
||||||
|
quit(1 if failures > 0 else 0)
|
||||||
1
tests/test_boundary.gd.uid
Normal file
1
tests/test_boundary.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://qlm8an6ka4dw
|
||||||
39
tests/test_terrain_edges.gd
Normal file
39
tests/test_terrain_edges.gd
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## Terrain edge check: there must be no ground below the inland waterline in the
|
||||||
|
## band just inside the boundary wall. Ground like that shows up as inland water
|
||||||
|
## hanging over a drop (the lake water surface stops at the wall) and makes pits
|
||||||
|
## the player can fall into and not climb out of.
|
||||||
|
##
|
||||||
|
## Run: godot --headless --path . --script res://tests/test_terrain_edges.gd
|
||||||
|
|
||||||
|
const BAND := 10.0 # metres inside the wall to check
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
var terrain: Node3D = load("res://scripts/world/terrain.gd").new()
|
||||||
|
root.add_child(terrain) # builds in _ready
|
||||||
|
_run.call_deferred(terrain)
|
||||||
|
|
||||||
|
func _run(terrain: Node3D) -> void:
|
||||||
|
var bad := 0
|
||||||
|
var worst := INF
|
||||||
|
var worst_at := Vector2.ZERO
|
||||||
|
var half: float = terrain.size * 0.5
|
||||||
|
var x := -half
|
||||||
|
while x < half:
|
||||||
|
var z := -half
|
||||||
|
while z < half:
|
||||||
|
if terrain.inside_boundary(x, z, 0.0) and not terrain.inside_boundary(x, z, BAND):
|
||||||
|
var h: float = terrain.sample_height(x, z)
|
||||||
|
if h < terrain.water_level + 0.2:
|
||||||
|
bad += 1
|
||||||
|
if h < worst:
|
||||||
|
worst = h
|
||||||
|
worst_at = Vector2(x, z)
|
||||||
|
z += 1.0
|
||||||
|
x += 1.0
|
||||||
|
if bad > 0:
|
||||||
|
print("FAIL: %d samples near the wall are below the waterline; lowest %.1f m at (%.0f, %.0f), sea weight %.2f" % [
|
||||||
|
bad, worst, worst_at.x, worst_at.y, terrain.sea_weight(worst_at.x, worst_at.y)])
|
||||||
|
else:
|
||||||
|
print("PASS: ground near the wall stays above the waterline")
|
||||||
|
quit(1 if bad > 0 else 0)
|
||||||
Reference in New Issue
Block a user