diff --git a/docs/ACTION_SYSTEM_ARCHITECTURE.md b/docs/ACTION_SYSTEM_ARCHITECTURE.md index 070eb18..68588f1 100644 --- a/docs/ACTION_SYSTEM_ARCHITECTURE.md +++ b/docs/ACTION_SYSTEM_ARCHITECTURE.md @@ -5,6 +5,7 @@ ```text SimulationManager tick -> ActionExecutionSystem advances needs and working progress + -> SimulationPopulationView refreshes transient living/starving indexes -> VillageOpportunitySystem re-derives the current capable helper -> ActionSelectionSystem chooses an action when the NPC is idle -> SimulationManager stores the selected action @@ -25,6 +26,8 @@ SimulationManager tick - queries `RelationshipSystem` after personal survival/schedule overrides, so a trusted starving acquaintance can redirect ordinary work toward food gathering while the pantry is low; +- consumes the manager's current `SimulationPopulationView` for that social + query instead of rebuilding an all-NPC lookup for each idle decision; - accepts the current ephemeral `OpportunityHelperResult` and, only for its matching NPC, selects the reported ordinary gather/deposit action after urgent self-care and sleep/meal schedule branches; @@ -71,7 +74,10 @@ SimulationManager tick SimulationManager remains the orchestrator and event boundary. It owns simulation records, invokes the focused systems, stores selected actions and targets, and translates presentation callbacks into simulation transitions. -It also publishes the latest `ActionSelectionResult` for presentation; the UI +It also owns one disposable `SimulationPopulationView`, rebuilt at tick start +and refreshed after each NPC advances so interleaved decision semantics remain +unchanged. +The manager publishes the latest `ActionSelectionResult` for presentation; the UI does not recompute decisions. Each idle selection re-derives the capable helper instead of consulting persisted assignment state. At completion it atomically pays any definition-backed stored-resource cost before applying the action @@ -128,6 +134,8 @@ simulation ticks. - stores the exact event ID as the trust cause and ignores replayed or older events; - selects trusted starving subjects deterministically for action selection; +- uses a supplied per-tick population view when available, with a compatibility + fallback for isolated callers; - does not choose actions, perform transactions, or format presentation text. These collaborators are `RefCounted` rule services, not additional scene-tree diff --git a/docs/ARCHITECTURE_OVERVIEW.md b/docs/ARCHITECTURE_OVERVIEW.md index 6fe9af2..a22d5a4 100644 --- a/docs/ARCHITECTURE_OVERVIEW.md +++ b/docs/ARCHITECTURE_OVERVIEW.md @@ -10,6 +10,7 @@ results. ```text SimulationClock -> SimulationManager orchestrates one deterministic tick + -> SimulationPopulationView indexes all/living/starving NPCs by stable ID -> ActionExecutionSystem advances needs and work -> VillageOpportunitySystem re-derives the current capable helper -> ActionSelectionSystem chooses an action @@ -42,6 +43,10 @@ would otherwise obscure that lifecycle: first without weakening its direct-source or one-hop checks; - `simulation/relationships/RelationshipSystem.gd` owns directed relationship queries, event-driven trust changes, and deterministic social tie-breaking; +- `simulation/population/SimulationPopulationView.gd` owns a transient derived + all/living/starving stable-ID index. The manager rebuilds it once at the tick + boundary and refreshes it after each interleaved NPC update; relationship and + action queries may read it, but it never enters save records; - `simulation/opportunities/VillageOpportunitySystem.gd` observes immutable event references plus current NPC/storage state, then owns the bounded shared open/resolved/invalidated lifecycle for the proven pantry-food and blocked- @@ -76,6 +81,7 @@ hard to read. | `simulation/events/` | Immutable event history and derived event queries | | `simulation/knowledge/` | Per-NPC knowledge of objective event IDs | | `simulation/relationships/` | Directed social consequences and relationship queries | +| `simulation/population/` | Transient per-tick population query indexes | | `simulation/opportunities/` | Knowledge-gated unresolved-condition projections | | `simulation/state/` | Versioned, serializable mutable records | | `simulation/definitions/` | Stable IDs and immutable gameplay definitions | @@ -118,6 +124,12 @@ improving ownership. - Benchmark fixtures use ordinary simulation records and must round-trip through the current state schema. They may control workload setup, but must not add benchmark-only fields or branches to production saves and ticks. +- Derived population indexes are disposable acceleration structures. NPC + records remain authoritative, and rebuilding the view after initialization, + restore, or a tick must produce the same decisions and checksum. +- Camera-local grass, ambient butterflies, water animation, smoke, and foliage + motion are presentation only. Grass consumes bounded real actor transforms; + it does not write NPC positions or become persistent state. - Opportunity records reference stable NPC, storage, resource, trigger-event, and resolution-event IDs. Their generator may observe authoritative state and history, but it does not mutate the economy or command NPC behavior. The diff --git a/docs/BUILD_IN_PUBLIC_PLAN.md b/docs/BUILD_IN_PUBLIC_PLAN.md index 98f33e3..de4ce43 100644 --- a/docs/BUILD_IN_PUBLIC_PLAN.md +++ b/docs/BUILD_IN_PUBLIC_PLAN.md @@ -412,6 +412,13 @@ coverage validates the texture contract, presentation nodes, terrain shape, stable resources, and navigation. Clip capture and a visual performance review remain presentation checkpoints rather than missing implementation. +`Jajce Center 02` adds the first reviewed composition pass beyond that baseline: +the summit citadel and residential roof amphitheater read as one center, the +waterfall feeds a Terrain3D-sampled turquoise ribbon, and bounded camera-local +grass, conifers, richer deciduous silhouettes, and oversized butterflies add +life without changing simulation authority. The matched review lives in +[`baselines/JAJCE_CENTER_02.md`](baselines/JAJCE_CENTER_02.md). + ### Exit condition The lookdev scene can produce a compelling 10–20 second clip with no gameplay. @@ -755,14 +762,25 @@ Completed: NPC and event counts. The first reviewed capture sets a local 600-NPC target, records identical checksums across samples, and exposes repeated scarce-food population scans plus objective-event growth as the next measured work. +33. Shared population query view: stable-ID all/living/starving indexes are + built once per deterministic tick and refreshed through interleaved NPC + updates. Baseline 02 preserves every checksum and improves the 600-NPC case + by 23.3% without simulation LOD. +34. `Jajce Center 02`: the reviewed matched capture strengthens the citadel, + cascading homes, bridge, waterfall, terrain-following river, conifer/tree + clusters, and ambient butterflies. Roughly 10,400 camera-local grass + particles remain short and patchy and bend from real actor transforms. Next: -1. Reuse one stable per-tick population view in the existing - trusted-starving-subject query, preserve exact action/checksum behavior, and - rerun simulation scaling baseline 01. Let the resulting profile determine - whether the following slice needs another bounded query optimization, a - spatial index, or the first active/abstract LOD contract. +1. Measure and implement a loaded-resource spatial query through + `ActiveWorldAdapter` for far-apart Terrain3D-authored finite sources. Keep + simulation-owned resource records, stable IDs, current score/reachability, + reservations, player parity, and unload/rebind behavior unchanged. +2. Sculpt a real Terrain3D river channel and waterfall shelf before adding a + broad foliage asset pass. Use Terrain3D particles for nearby grass and + intentional instancing for tree/fern/rock masses; add livestock only with a + real identity and interaction contract. Do not start with GIS data, a full city, a large asset pack, or more NPC mechanics. The next proof is a beautiful stage for the systems that already diff --git a/docs/LEARNING_ROADMAP.md b/docs/LEARNING_ROADMAP.md index 70e81a8..2625b7c 100644 --- a/docs/LEARNING_ROADMAP.md +++ b/docs/LEARNING_ROADMAP.md @@ -469,8 +469,10 @@ important history. [Simulation scaling baseline 01](benchmarks/SIMULATION_SCALING_BASELINE_01.md) sets the first local target at 600 data-only full-fidelity NPCs while preserving -deterministic state. Revisit that target when world presentation or LOD enters -the measured workload. +deterministic state. [Baseline 02](benchmarks/SIMULATION_SCALING_BASELINE_02.md) +keeps identical final checksums after one reusable per-tick population view and +improves that case from 65.84 to 85.81 ticks per second. Revisit the target when +world presentation or LOD enters the measured workload. ## Milestone 9 — Player interaction and social agency @@ -862,15 +864,35 @@ workload exclusions, checksums, and local 50-ticks-per-second reference target live in [Simulation scaling baseline 01](benchmarks/SIMULATION_SCALING_BASELINE_01.md). -The immediate next slice should build one bounded per-tick population view for -the existing trusted-starving-subject query. That scarce-food path currently -rebuilds an all-NPC lookup for each applicable idle decision, making it the -first code-level candidate consistent with the measured superlinear growth. -Preserve exact selection/checksum behavior and rerun the same ledger before -choosing a spatial index or active/abstract LOD design. +The first measured optimization is also complete. `SimulationPopulationView` +builds stable-ID all/living/starving indexes once per tick, refreshes them after +each interleaved NPC update, and serves relationship/action queries without +entering saved state. Every baseline-02 checksum exactly matches baseline 01. +The 600-NPC case falls from 15,187.74 to 11,654.10 microseconds per tick, a +23.3% improvement, while small fixtures expose the expected fixed dictionary +cost. See +[Simulation scaling baseline 02](benchmarks/SIMULATION_SCALING_BASELINE_02.md). + +The immediate next systems slice should now prove spatial discovery for the +world vision: index loaded finite `ResourceNode` anchors through +`ActiveWorldAdapter` so Terrain3D-authored sources can be placed much farther +apart without an all-node scan for every resolution. Preserve stable IDs, +current distance/risk/comfort/priority scoring, reachability, reservations, +player parity, unload/rebind authority, and exact deterministic outcomes. First +measure 18, 180, and 1,800 loaded candidates; do not choose a generic spatial +framework or active/abstract LOD mode before that real consumer proves the +contract. Recently completed: +- Shared population query view: one transient per-tick all/living/starving + index replaces repeated scarce-food relationship scans, preserves exact + checksums, and improves the reviewed 600-NPC workload by 23.3%. +- `Jajce Center 02`: matched before/after captures now document a larger summit + citadel, cascading timber-plaster homes, terrain-following turquoise river, + rock-framed waterfall, richer tree silhouettes, restrained conifer clusters, + oversized butterflies, and short camera-local grass that bends around real + player/NPC transforms without becoming simulation state. - Deterministic simulation scaling baseline: schema-valid full-fidelity fixtures, a repeatable CLI runner, and a fast headless regression now record population throughput, phase timing, serialized-state growth, history diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index 6d251c8..e131a54 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -2,7 +2,7 @@ > Agent-facing context for understanding the project quickly. > -> Snapshot basis: repository state on July 14, 2026. Treat the code as the +> Snapshot basis: repository state on July 16, 2026. Treat the code as the > source of truth when this document and the implementation differ. See the [documentation map](README.md) for the authority and scope of each plan. @@ -193,12 +193,15 @@ study, and patrol target typed activity sites. The migration is documented in and GUT run through the local quality gate) - **Main scene:** `res://main.tscn` - **Terrain:** Terrain3D 1.0.2 is installed and enabled -- **Jajce runtime:** reusable 512 m Terrain3D seed, greybox landmarks, stable - ResourceNode placement, lookdev camera, and Terrain3D-derived runtime - navigation with collision guardrails +- **Jajce runtime:** reusable 512 m Terrain3D seed, stable ResourceNode + placement, larger citadel/village-center composition, terrain-following river + ribbon, reactive camera-local grass, and Terrain3D-derived runtime navigation + with collision guardrails - **Lookdev baseline:** `Jajce Lookdev 01` is captured at `docs/baselines/jajce_lookdev_01.png` with `tools/capture_jajce_lookdev.gd` +- **Matched center baseline:** `Jajce Center 02` preserves a fixed before/after + overview plus a close grass-readability capture under `docs/baselines/` - **Runtime baseline:** `Simulation Garden 01` is captured as paired debug and cinematic `main.tscn` images plus an empty/restocked pantry close pair under `docs/baselines/` with `tools/capture_simulation_garden.gd` @@ -336,13 +339,16 @@ distance with resource `safety_risk`, `comfort_distance`, and - a centered 512 m Terrain3D landscape split across four regions; - deterministic shaped terrain data and six game-owned surface layers; -- fortress, houses, mill, bridge, shader river/waterfall, mist, and smoke; +- a larger summit citadel, eight cascading houses, mill, full-span bridge, + rock-framed waterfall, mist, smoke, and a Terrain3D-sampled river ribbon; - authored dirt path strips that make the current village task loop readable; - compact silhouette props for the ridge landmark, guard, study, and rest sites, plus a physical pantry whose empty/low/stocked arrangement follows authoritative food storage; - warm sky, fog, shadows, coherent wind-reactive foliage, and breeze-aligned chimney smoke; +- short patchy camera-local grass that receives at most eight real player/NPC + transforms, authored conifer clusters, and oversized ambient butterflies; - one sparse terrain-aware calligraphic gust field with two or three soft tapered strokes per burst; - eighteen ResourceNodes preserving stable food/wood discovery IDs; @@ -418,6 +424,8 @@ views, modifiers, and priorities. Focused `RefCounted` collaborators keep rule ownership visible: - action systems own selection, execution progress, and target resolution; +- `SimulationPopulationView` owns the disposable all/living/starving stable-ID + query indexes rebuilt at deterministic tick boundaries; - `VillageEconomy` owns storage/inventory transactions and synchronized village resource views; - `SimulationEventLog` owns deterministic event history and queries; @@ -545,6 +553,7 @@ NpcVisual navigates through the active world │ ├── events/ Ordered event history and queries │ ├── knowledge/ Per-NPC facts, provenance, transfer, and retention │ ├── opportunities/ Knowledge-gated unresolved-condition projection +│ ├── population/ Transient per-tick NPC query indexes │ ├── persistence/ Validated local save-slot storage │ ├── relationships/ Directed social consequences and queries │ └── state/ Versioned simulation-state records diff --git a/docs/baselines/JAJCE_CENTER_02.md b/docs/baselines/JAJCE_CENTER_02.md new file mode 100644 index 0000000..95a34a6 --- /dev/null +++ b/docs/baselines/JAJCE_CENTER_02.md @@ -0,0 +1,95 @@ +# Jajce Center 02 + +Captured: 2026-07-16 + +## Files + +- [before overview](jajce_center_02_before.png) +- [after overview](jajce_center_02_after.png) +- [close gameplay grass check](jajce_grass_gameplay_02.png) +- `tools/capture_jajce_lookdev.gd` + +The two overview images use the same 1280×720 camera, fixed daylight value, +warmup, and Metal renderer. The gameplay image is a separate readability check, +not part of the matched comparison. + +## Reference reading + +The reduction uses Jajce's hierarchy rather than attempting a literal city +reconstruction: + +- the [UNESCO tentative-list description](https://whc.unesco.org/en/tentativelists/2098/) + describes the citadel at the summit of a rocky pyramid, ramparts descending + its slopes, and residential roofs forming an amphitheater beneath it; +- the [official Jajce fortress overview](https://www.agencija-jajce.ba/en/the-fortress/) + supports treating the fortress as the primary long-distance landmark; +- official Studio Ghibli still collections for + [My Neighbor Totoro](https://www.ghibli.jp/works/totoro/) and + [Princess Mononoke](https://www.ghibli.jp/works/mononoke/) were used only as + broad references for layered green masses, clear silhouettes, restrained + natural motion, and small warm human details inside large landscapes; +- [Tiny Glade](https://store.steampowered.com/app/2198150/Tiny_Glade/) supports + the low-detail diorama reading of whimsical castles, cottages, ruins, and + incidental life; +- the [Terrain3D instancer guidance](https://terrain3d.readthedocs.io/en/latest/docs/instancer.html) + informed the split between camera-local particle grass and intentional tree + placement. + +No building, frame, or asset is copied from those references. The project keeps +its own compact low-poly language and real simulation constraints. + +## Implemented reduction + +- The ridge block becomes a larger warm-stone citadel with a rocky plinth, + ramparts, descending walls, towers, gate, crenellations, banner, and narrow + window slits. +- Eight timber-accented plaster houses now cascade below the citadel instead of + leaving three isolated boxes in an empty field. +- The waterfall is wider, softly animated, misted, and framed by irregular + mossy rock columns. Its plunge pool feeds a turquoise ribbon that samples the + live Terrain3D height field instead of disappearing beneath it. +- The bridge gains a full river span, stone abutments, center pier, rails, and + posts. House windows and chimney smoke add small warm points. +- Existing rounded deciduous trees are richer and wind-reactive; authored + conifer clusters strengthen the citadel, falls, and valley edges. +- Three intentionally oversized butterflies add quiet ambient life. +- A bounded camera-local Terrain3D grass field uses roughly 10,400 particles. + Its project-owned process material keeps blades short and patchy, while a + shader receives at most eight real player/NPC transforms and bends grass + away from their feet. +- Haze and saturation are reduced so green terrain, warm roofs, stone, and + turquoise water separate more clearly. + +## Authority boundary + +Grass, butterflies, smoke, water motion, and foliage sway are transient +presentation. They do not enter saves or checksums. Player and `NpcVisual` +nodes merely expose real transforms to the grass shader. + +Finite food and wood still come only from stable-ID `ResourceNode` anchors +bound to simulation-owned records. The conifers and grass are decorative; the +next resource-discovery phase can pair selected Terrain3D-authored visuals with +intentional resource anchors without turning every blade or background tree +into authoritative state. Livestock is deliberately deferred until one animal +has a real identity/state/interaction contract; ambient butterflies do not +pretend to be simulated food or work. + +## Remaining visual work + +This is a stronger prototype composition, not final environment art. The next +art pass should sculpt a true river channel and waterfall shelf into Terrain3D, +replace procedural fortress/house geometry gradually, add bank stones and +flower/fern clusters through bounded instancing, and validate a representative +frame-time budget. The matched comparison should remain the checkpoint before +those asset-heavy changes. + +## Capture command + +```bash +HOME="$PWD/logs/quality/godot_profile" \ +APPDATA="$PWD/logs/quality/godot_profile" \ +LOCALAPPDATA="$PWD/logs/quality/godot_profile" \ +/Applications/Godot.app/Contents/MacOS/Godot \ + --path "$PWD" --script res://tools/capture_jajce_lookdev.gd -- \ + --output=res://docs/baselines/jajce_center_02_after.png +``` diff --git a/docs/baselines/jajce_center_02_after.png b/docs/baselines/jajce_center_02_after.png new file mode 100644 index 0000000..add5c72 Binary files /dev/null and b/docs/baselines/jajce_center_02_after.png differ diff --git a/docs/baselines/jajce_center_02_after.png.import b/docs/baselines/jajce_center_02_after.png.import new file mode 100644 index 0000000..883a381 --- /dev/null +++ b/docs/baselines/jajce_center_02_after.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dcg13u5wqr23m" +path="res://.godot/imported/jajce_center_02_after.png-be0a331c22c145d626fa09f682c7e379.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://docs/baselines/jajce_center_02_after.png" +dest_files=["res://.godot/imported/jajce_center_02_after.png-be0a331c22c145d626fa09f682c7e379.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/docs/baselines/jajce_center_02_before.png b/docs/baselines/jajce_center_02_before.png new file mode 100644 index 0000000..9e5d863 Binary files /dev/null and b/docs/baselines/jajce_center_02_before.png differ diff --git a/docs/baselines/jajce_center_02_before.png.import b/docs/baselines/jajce_center_02_before.png.import new file mode 100644 index 0000000..0b8a32d --- /dev/null +++ b/docs/baselines/jajce_center_02_before.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dipu1epmps356" +path="res://.godot/imported/jajce_center_02_before.png-a6dad6237a960278853b124b5c1180c2.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://docs/baselines/jajce_center_02_before.png" +dest_files=["res://.godot/imported/jajce_center_02_before.png-a6dad6237a960278853b124b5c1180c2.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/docs/baselines/jajce_grass_gameplay_02.png b/docs/baselines/jajce_grass_gameplay_02.png new file mode 100644 index 0000000..7b0b545 Binary files /dev/null and b/docs/baselines/jajce_grass_gameplay_02.png differ diff --git a/docs/baselines/jajce_grass_gameplay_02.png.import b/docs/baselines/jajce_grass_gameplay_02.png.import new file mode 100644 index 0000000..5dcf255 --- /dev/null +++ b/docs/baselines/jajce_grass_gameplay_02.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://du1awnpktb7q0" +path="res://.godot/imported/jajce_grass_gameplay_02.png-4b072b6e4f1c146d62492370903db793.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://docs/baselines/jajce_grass_gameplay_02.png" +dest_files=["res://.godot/imported/jajce_grass_gameplay_02.png-4b072b6e4f1c146d62492370903db793.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/docs/benchmarks/README.md b/docs/benchmarks/README.md index c21bc02..c3b7c80 100644 --- a/docs/benchmarks/README.md +++ b/docs/benchmarks/README.md @@ -21,3 +21,6 @@ Reviewed captures: - [Simulation scaling baseline 01](SIMULATION_SCALING_BASELINE_01.md) records the first full-fidelity population/history measurements and the bounded next optimization. +- [Simulation scaling baseline 02](SIMULATION_SCALING_BASELINE_02.md) records + checksum-identical results after the shared per-tick population view: the + 600-NPC case is 23.3% faster, while small fixtures expose its fixed cost. diff --git a/docs/benchmarks/SIMULATION_SCALING_BASELINE_02.md b/docs/benchmarks/SIMULATION_SCALING_BASELINE_02.md new file mode 100644 index 0000000..ba9a3a4 --- /dev/null +++ b/docs/benchmarks/SIMULATION_SCALING_BASELINE_02.md @@ -0,0 +1,70 @@ +# Simulation scaling baseline 02 + +This is the reviewed comparison after introducing one reusable per-tick +`SimulationPopulationView`. The view indexes all, living, and starving NPCs by +stable ID, is refreshed as each NPC advances, and is shared by relationship and +action-selection queries. It is transient derived state and is not serialized. + +The machine-readable samples are in +[`simulation_scaling_baseline_02.json`](simulation_scaling_baseline_02.json). +The workload, seed, host, exclusions, warmup, and three-sample median contract +match [baseline 01](SIMULATION_SCALING_BASELINE_01.md). + +## Results + +| Case | NPCs | Seeded history | Median us/tick | Ticks/s | Simulated realtime | Arrival share | End JSON | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| Population 6 | 6 | 0 | 63.85 | 15,662.93 | 18,795.5x | 4.5% | 0.06 MiB | +| Population 60 | 60 | 0 | 662.62 | 1,509.17 | 1,811.0x | 5.7% | 0.56 MiB | +| Population 600 | 600 | 0 | 11,654.10 | 85.81 | 103.0x | 18.0% | 5.67 MiB | +| History 600 | 60 | 600 | 722.60 | 1,383.90 | 1,660.7x | 5.2% | 0.71 MiB | +| History 6,000 | 60 | 6,000 | 1,430.65 | 698.98 | 838.8x | 2.6% | 2.04 MiB | + +All samples within each case were deterministic. More importantly, every final +checksum exactly matches the corresponding baseline-01 checksum, so the +optimization preserves selection, interleaved NPC updates, event growth, and +serialized continuation state. + +## Comparison + +| Case | Baseline 01 us/tick | Baseline 02 us/tick | Change | +| --- | ---: | ---: | ---: | +| Population 6 | 55.74 | 63.85 | 14.6% slower | +| Population 60 | 621.87 | 662.62 | 6.6% slower | +| Population 600 | 15,187.74 | 11,654.10 | **23.3% faster** | +| History 600 | 677.12 | 722.60 | 6.7% slower | +| History 6,000 | 1,421.19 | 1,430.65 | 0.7% slower | + +The shared dictionaries add a small fixed per-tick cost, visible in tiny +fixtures and normal local timing noise. At the measured population knee they +remove repeated all-NPC lookup construction and improve the 600-NPC case from +65.84 to 85.81 ticks per second. The local 50-ticks-per-second reference target +remains comfortably met without introducing simulation LOD. + +## Decision and next measurement + +Keep the population view. It has a real relationship/action consumer, improves +the measured pressure point, and preserves exact state. Do not add batching or +active/abstract NPC modes merely to improve this data-only number. + +The next bounded scale slice should support the world vision directly: a +loaded-resource spatial query owned by `ActiveWorldAdapter`. It should let +villagers discover finite, stable-ID `ResourceNode` anchors placed much farther +apart across Terrain3D while preserving the current score, reachability, +reservation, and save/rebind contracts. A benchmark should compare 18, 180, +and 1,800 loaded candidates before choosing a grid, tree, or other index. + +Terrain3D-instanced visual trees and grass are not automatically authoritative +resources. Intentional harvest anchors must continue to bind simulation-owned +`ResourceStateRecord` data by stable ID; large decorative populations can stay +presentation-only until a gameplay rule needs them. + +## Capture command + +```bash +/Applications/Godot.app/Contents/MacOS/Godot \ + --headless --path "$PWD" \ + --script res://tools/benchmark_simulation_scaling.gd -- \ + --host-label=Apple_M1_Max_64_GB \ + --output=res://docs/benchmarks/simulation_scaling_baseline_02.json +``` diff --git a/docs/benchmarks/simulation_scaling_baseline_02.json b/docs/benchmarks/simulation_scaling_baseline_02.json new file mode 100644 index 0000000..8f18b27 --- /dev/null +++ b/docs/benchmarks/simulation_scaling_baseline_02.json @@ -0,0 +1,308 @@ +{ + "benchmark_seed": 8088, + "captured_utc": "2026-07-16T21:38:42Z", + "cases": [ + { + "arrival_share_percent": 4.47176756206437, + "arrival_usec_median": 571, + "arrival_usec_samples": [ + 563, + 571, + 579 + ], + "arrivals_processed": 209, + "case_id": "population_006", + "elapsed_usec_max": 12771, + "elapsed_usec_median": 12769, + "elapsed_usec_min": 12507, + "elapsed_usec_samples": [ + 12507, + 12769, + 12771 + ], + "end_event_count": 221, + "end_known_reference_count": 0, + "end_state_bytes": 58576, + "end_tick": 210, + "events_recorded": 209, + "final_checksum": "100dd5dc1e0c22ea23f35dd876d6f56fd5c33c58b1586b6fa9aec47ee1536511", + "history_seed_events": 0, + "measured_ticks": 200, + "npc_updates": 1200, + "population": 6, + "realtime_factor_median": 18795.5204009711, + "sample_count": 3, + "schema_version": 1, + "seed": 8088, + "setup_usec_median": 627, + "setup_usec_samples": [ + 546, + 627, + 6728 + ], + "simulation_usec_median": 12173, + "simulation_usec_samples": [ + 11916, + 12173, + 12186 + ], + "start_event_count": 12, + "start_known_reference_count": 0, + "start_state_bytes": 7632, + "start_tick": 10, + "state_growth_bytes": 50944, + "tick_interval": 1.2, + "ticks_per_second_median": 15662.9336674759, + "usec_per_tick_median": 63.845, + "warmup_arrivals": 12, + "warmup_ticks": 10, + "workload_id": "full_fidelity_headless_arrival" + }, + { + "arrival_share_percent": 5.66845000490481, + "arrival_usec_median": 7512, + "arrival_usec_samples": [ + 7458, + 7512, + 7571 + ], + "arrivals_processed": 2090, + "case_id": "population_060", + "elapsed_usec_max": 133541, + "elapsed_usec_median": 132523, + "elapsed_usec_min": 130182, + "elapsed_usec_samples": [ + 130182, + 132523, + 133541 + ], + "end_event_count": 2210, + "end_known_reference_count": 0, + "end_state_bytes": 587324, + "end_tick": 210, + "events_recorded": 2090, + "final_checksum": "58c7f96fc5df7f5399fd82d1a9c212b3c53067e807059e88ffa484ba0eb41e16", + "history_seed_events": 0, + "measured_ticks": 200, + "npc_updates": 12000, + "population": 60, + "realtime_factor_median": 1811.00639134339, + "sample_count": 3, + "schema_version": 1, + "seed": 8088, + "setup_usec_median": 3788, + "setup_usec_samples": [ + 3481, + 3788, + 3917 + ], + "simulation_usec_median": 125039, + "simulation_usec_samples": [ + 122647, + 125039, + 125926 + ], + "start_event_count": 120, + "start_known_reference_count": 0, + "start_state_bytes": 71278, + "start_tick": 10, + "state_growth_bytes": 516046, + "tick_interval": 1.2, + "ticks_per_second_median": 1509.17199278616, + "usec_per_tick_median": 662.615, + "warmup_arrivals": 120, + "warmup_ticks": 10, + "workload_id": "full_fidelity_headless_arrival" + }, + { + "arrival_share_percent": 17.9571567087978, + "arrival_usec_median": 418549, + "arrival_usec_samples": [ + 417929, + 418549, + 425849 + ], + "arrivals_processed": 20950, + "case_id": "population_600", + "elapsed_usec_max": 2346707, + "elapsed_usec_median": 2330820, + "elapsed_usec_min": 2326981, + "elapsed_usec_samples": [ + 2326981, + 2330820, + 2346707 + ], + "end_event_count": 22150, + "end_known_reference_count": 0, + "end_state_bytes": 5948447, + "end_tick": 210, + "events_recorded": 20950, + "final_checksum": "fa4efac63fde63f92eb28141bebd1922f665e85ffe7b7ac2b9702dae6e4c24b7", + "history_seed_events": 0, + "measured_ticks": 200, + "npc_updates": 120000, + "population": 600, + "realtime_factor_median": 102.968054161196, + "sample_count": 3, + "schema_version": 1, + "seed": 8088, + "setup_usec_median": 34966, + "setup_usec_samples": [ + 34505, + 34966, + 34980 + ], + "simulation_usec_median": 1912210, + "simulation_usec_samples": [ + 1908995, + 1912210, + 1920790 + ], + "start_event_count": 1200, + "start_known_reference_count": 0, + "start_state_bytes": 713361, + "start_tick": 10, + "state_growth_bytes": 5235086, + "tick_interval": 1.2, + "ticks_per_second_median": 85.8067118009971, + "usec_per_tick_median": 11654.1, + "warmup_arrivals": 1200, + "warmup_ticks": 10, + "workload_id": "full_fidelity_headless_arrival" + }, + { + "arrival_share_percent": 5.24844484116275, + "arrival_usec_median": 7585, + "arrival_usec_samples": [ + 7540, + 7585, + 7604 + ], + "arrivals_processed": 2090, + "case_id": "history_000600", + "elapsed_usec_max": 145539, + "elapsed_usec_median": 144519, + "elapsed_usec_min": 144335, + "elapsed_usec_samples": [ + 144335, + 144519, + 145539 + ], + "end_event_count": 2810, + "end_known_reference_count": 0, + "end_state_bytes": 743004, + "end_tick": 810, + "events_recorded": 2090, + "final_checksum": "7ae86044f7683359d6bfbc8753e2656f3af90f23d040039154c6869dca48cf65", + "history_seed_events": 600, + "measured_ticks": 200, + "npc_updates": 12000, + "population": 60, + "realtime_factor_median": 1660.68129450107, + "sample_count": 3, + "schema_version": 1, + "seed": 8088, + "setup_usec_median": 22723, + "setup_usec_samples": [ + 21887, + 22723, + 23413 + ], + "simulation_usec_median": 136897, + "simulation_usec_samples": [ + 136707, + 136897, + 137966 + ], + "start_event_count": 720, + "start_known_reference_count": 180, + "start_state_bytes": 253098, + "start_tick": 610, + "state_growth_bytes": 489906, + "tick_interval": 1.2, + "ticks_per_second_median": 1383.90107875089, + "usec_per_tick_median": 722.595, + "warmup_arrivals": 120, + "warmup_ticks": 10, + "workload_id": "full_fidelity_headless_arrival" + }, + { + "arrival_share_percent": 2.64320413797924, + "arrival_usec_median": 7563, + "arrival_usec_samples": [ + 7552, + 7563, + 7628 + ], + "arrivals_processed": 2090, + "case_id": "history_006000", + "elapsed_usec_max": 291609, + "elapsed_usec_median": 286130, + "elapsed_usec_min": 285483, + "elapsed_usec_samples": [ + 285483, + 286130, + 291609 + ], + "end_event_count": 8210, + "end_known_reference_count": 0, + "end_state_bytes": 2141615, + "end_tick": 6210, + "events_recorded": 2090, + "final_checksum": "b4df2bac21ed5ce6dcd92736e210db5f3114d959dcb15ae7796493362911b6d7", + "history_seed_events": 6000, + "measured_ticks": 200, + "npc_updates": 12000, + "population": 60, + "realtime_factor_median": 838.779575717331, + "sample_count": 3, + "schema_version": 1, + "seed": 8088, + "setup_usec_median": 153306, + "setup_usec_samples": [ + 153290, + 153306, + 154064 + ], + "simulation_usec_median": 278467, + "simulation_usec_samples": [ + 277892, + 278467, + 284022 + ], + "start_event_count": 6120, + "start_known_reference_count": 180, + "start_state_bytes": 1649700, + "start_tick": 6010, + "state_growth_bytes": 491915, + "tick_interval": 1.2, + "ticks_per_second_median": 698.982979764443, + "usec_per_tick_median": 1430.65, + "warmup_arrivals": 120, + "warmup_ticks": 10, + "workload_id": "full_fidelity_headless_arrival" + } + ], + "engine_version": "4.7-stable (official)", + "exclusions": [ + "manager_and_fixture_setup", + "state_serialization", + "world_scene", + "rendering", + "navigation", + "npc_visual" + ], + "host_label": "Apple_M1_Max_64_GB", + "measured_ticks": 200, + "platform": "macOS", + "processor_count": 10, + "sample_count": 3, + "schema_version": 1, + "timed_phases": [ + "simulation_tick", + "headless_arrival_completion" + ], + "warmup_ticks": 10, + "workload": "All NPCs receive full per-tick needs/task updates and ordinary action decisions; travel resolves through the deterministic immediate-arrival headless convention.", + "workload_id": "full_fidelity_headless_arrival" +} diff --git a/player/npc/NpcVisual.gd b/player/npc/NpcVisual.gd index 3f13af1..ca74c92 100644 --- a/player/npc/NpcVisual.gd +++ b/player/npc/NpcVisual.gd @@ -66,6 +66,7 @@ func debug_log(message: String) -> void: func _ready() -> void: + add_to_group("grass_interactors") dead_material.albedo_color = Color(0.25, 0.25, 0.25, 1.0) _stop_trust_gain_reaction() diff --git a/player/player.gd b/player/player.gd index 8f4e2e8..c4ff3cd 100644 --- a/player/player.gd +++ b/player/player.gd @@ -15,6 +15,10 @@ extends CharacterBody3D @export var interaction_range := 3.0 +func _ready() -> void: + add_to_group("grass_interactors") + + func _physics_process(delta: float) -> void: var input := Input.get_vector("move_left", "move_right", "move_forward", "move_backward") diff --git a/simulation/SimulationManager.gd b/simulation/SimulationManager.gd index 649bf20..f1d2e25 100644 --- a/simulation/SimulationManager.gd +++ b/simulation/SimulationManager.gd @@ -56,6 +56,7 @@ var latest_decisions: Dictionary = {} var action_selector := ActionSelectionSystem.new() var action_executor := ActionExecutionSystem.new() var target_resolver := ActionTargetResolver.new() +var _population_view := SimulationPopulationView.new() var speed_index := 2 const SPEED_LEVELS := [0.25, 0.5, 1.0, 2.0, 4.0, 10.0] const KNOWLEDGE_COMMUNICATION_RADIUS := 2.5 @@ -134,6 +135,7 @@ func generate_npcs() -> void: if home_count > 0: for i in range(npcs.size()): npcs[i].home_position = home_positions[i % home_count] + _population_view.rebuild(npcs) func _create_random_source(npc_id: int, stream_id: int) -> RandomNumberGenerator: @@ -155,6 +157,7 @@ func simulate_tick() -> void: if debug_logs: print("--- Tick ", tick_count, " ---") var village_was_changed := false + _population_view.rebuild(npcs) for npc in npcs: village_was_changed = _simulate_npc_tick(npc) or village_was_changed if village_was_changed: @@ -178,6 +181,7 @@ func _simulate_npc_tick(npc: SimNPC) -> bool: var was_dead := npc.is_dead action_executor.advance_npc(npc, village) + _population_view.refresh_npc(npc) _select_action_if_idle(npc, previous_state) if previous_task != npc.current_task and not previous_target.is_empty(): @@ -192,6 +196,7 @@ func _simulate_npc_tick(npc: SimNPC) -> bool: var completed := not was_complete and npc.task_complete and not npc.is_dead if completed: _complete_current_action(npc) + _population_view.refresh_npc(npc) _debug_npc_tick(npc, previous_state) return completed @@ -205,7 +210,9 @@ func _select_action_if_idle(npc: SimNPC, previous_state: StringName) -> void: if npc.task_state not in [SimNPC.TASK_STATE_IDLE, SimNPC.TASK_STATE_COMPLETE]: return var helper := get_active_opportunity_helper() - var selection := action_selector.select_action(npc, village, clock.time_of_day(), npcs, helper) + var selection := action_selector.select_action( + npc, village, clock.time_of_day(), npcs, helper, _population_view + ) if selection == null: return latest_decisions[npc.id] = selection @@ -509,7 +516,7 @@ func get_activity_target_claim_count(target_id: StringName, except_npc_id: int = func _notify_mourning(dead_npc: SimNPC) -> void: - var mourner := relationship_system.get_most_familiar_living_subject(dead_npc.id, npcs) + var mourner := relationship_system.get_most_familiar_living(dead_npc.id, npcs, _population_view) if mourner == null: return mourner.mourning_ticks = 100 @@ -761,10 +768,7 @@ func _get_lasting_event_ids(npc_id: int) -> Array[int]: func _find_npc_by_id(npc_id: int) -> SimNPC: - for npc in npcs: - if npc.id == npc_id: - return npc - return null + return _population_view.get_any(npc_id) static func _sort_npcs_by_id(first: SimNPC, second: SimNPC) -> bool: @@ -1044,11 +1048,7 @@ func add_knowledge(amount: float) -> void: func get_starving_count() -> int: - var count := 0 - for npc in npcs: - if npc.is_starving: - count += 1 - return count + return _population_view.starving_count() func get_state_snapshot() -> Dictionary: @@ -1178,6 +1178,7 @@ func restore_state(record: SimulationStateRecord) -> bool: npcs.clear() for npc_record in record.npcs: npcs.append(npc_record.restore(debug_logs)) + _population_view.rebuild(npcs) relationship_system.restore(record.relationships) event_knowledge_system.restore(record.event_knowledge) opportunity_system.restore(record.opportunities, int(record.simulation["next_opportunity_id"])) diff --git a/simulation/actions/ActionSelectionSystem.gd b/simulation/actions/ActionSelectionSystem.gd index bb4470d..869f4fd 100644 --- a/simulation/actions/ActionSelectionSystem.gd +++ b/simulation/actions/ActionSelectionSystem.gd @@ -22,7 +22,8 @@ func select_action( village: SimVillage, time_of_day: float = 0.5, all_npcs: Array = [], - opportunity_helper: OpportunityHelperResult = null + opportunity_helper: OpportunityHelperResult = null, + population_view: SimulationPopulationView = null ) -> ActionSelectionResult: if npc.is_dead: return null @@ -127,7 +128,7 @@ func select_action( ) if village.food <= RELATIONSHIP_AID_PANTRY_THRESHOLD and relationship_system != null: var trusted_starving_npc: SimNPC = relationship_system.get_trusted_starving_subject( - npc.id, all_npcs + npc.id, all_npcs, population_view ) if trusted_starving_npc != null: return ActionSelectionResult.new( diff --git a/simulation/population/SimulationPopulationView.gd b/simulation/population/SimulationPopulationView.gd new file mode 100644 index 0000000..e45f035 --- /dev/null +++ b/simulation/population/SimulationPopulationView.gd @@ -0,0 +1,55 @@ +class_name SimulationPopulationView +extends RefCounted + +var all_by_id: Dictionary = {} +var living_by_id: Dictionary = {} +var starving_by_id: Dictionary = {} + + +func _init(npcs: Array = []) -> void: + rebuild(npcs) + + +func rebuild(npcs: Array) -> void: + all_by_id.clear() + living_by_id.clear() + starving_by_id.clear() + for value in npcs: + var npc := value as SimNPC + if npc != null: + refresh_npc(npc) + + +func refresh_npc(npc: SimNPC) -> void: + if npc == null: + return + all_by_id[npc.id] = npc + if npc.is_dead: + living_by_id.erase(npc.id) + starving_by_id.erase(npc.id) + return + living_by_id[npc.id] = npc + if npc.is_starving: + starving_by_id[npc.id] = npc + else: + starving_by_id.erase(npc.id) + + +func get_living(npc_id: int) -> SimNPC: + return living_by_id.get(npc_id) as SimNPC + + +func get_any(npc_id: int) -> SimNPC: + return all_by_id.get(npc_id) as SimNPC + + +func get_starving(npc_id: int) -> SimNPC: + return starving_by_id.get(npc_id) as SimNPC + + +func living_count() -> int: + return living_by_id.size() + + +func starving_count() -> int: + return starving_by_id.size() diff --git a/simulation/population/SimulationPopulationView.gd.uid b/simulation/population/SimulationPopulationView.gd.uid new file mode 100644 index 0000000..e015d8b --- /dev/null +++ b/simulation/population/SimulationPopulationView.gd.uid @@ -0,0 +1 @@ +uid://btwlag4mpji24 diff --git a/simulation/relationships/RelationshipSystem.gd b/simulation/relationships/RelationshipSystem.gd index e4671c6..9b43cb9 100644 --- a/simulation/relationships/RelationshipSystem.gd +++ b/simulation/relationships/RelationshipSystem.gd @@ -95,16 +95,17 @@ func get_primary_relationship(observer_id: int) -> RelationshipStateRecord: return best -func get_most_familiar_living_subject(observer_id: int, npcs: Array[SimNPC]) -> SimNPC: - var living_by_id := {} - for npc in npcs: - if not npc.is_dead: - living_by_id[npc.id] = npc +func get_most_familiar_living( + observer_id: int, npcs: Array[SimNPC], population_view: SimulationPopulationView = null +) -> SimNPC: + var effective_view := population_view + if effective_view == null: + effective_view = SimulationPopulationView.new(npcs) var best_relationship: RelationshipStateRecord for relationship in relationships.values(): if relationship.get_observer_id() != observer_id: continue - if not living_by_id.has(relationship.get_subject_id()): + if effective_view.get_living(relationship.get_subject_id()) == null: continue if ( best_relationship == null @@ -117,21 +118,22 @@ func get_most_familiar_living_subject(observer_id: int, npcs: Array[SimNPC]) -> best_relationship = relationship if best_relationship == null: return null - return living_by_id[best_relationship.get_subject_id()] as SimNPC + return effective_view.get_living(best_relationship.get_subject_id()) -func get_trusted_starving_subject(observer_id: int, npcs: Array[SimNPC]) -> SimNPC: - var starving_by_id := {} - for npc in npcs: - if not npc.is_dead and npc.is_starving: - starving_by_id[npc.id] = npc +func get_trusted_starving_subject( + observer_id: int, npcs: Array, population_view: SimulationPopulationView = null +) -> SimNPC: + var effective_view := population_view + if effective_view == null: + effective_view = SimulationPopulationView.new(npcs) var best_relationship: RelationshipStateRecord for relationship in relationships.values(): if relationship.get_observer_id() != observer_id: continue if relationship.get_trust() < TRUSTED_HELP_THRESHOLD: continue - if not starving_by_id.has(relationship.get_subject_id()): + if effective_view.get_starving(relationship.get_subject_id()) == null: continue if ( best_relationship == null @@ -144,7 +146,7 @@ func get_trusted_starving_subject(observer_id: int, npcs: Array[SimNPC]) -> SimN best_relationship = relationship if best_relationship == null: return null - return starving_by_id[best_relationship.get_subject_id()] as SimNPC + return effective_view.get_starving(best_relationship.get_subject_id()) func _get_or_create(observer_id: int, subject_id: int) -> RelationshipStateRecord: diff --git a/tests/jajce_runtime_integration_test.gd b/tests/jajce_runtime_integration_test.gd index b9e38c3..fe4f34e 100644 --- a/tests/jajce_runtime_integration_test.gd +++ b/tests/jajce_runtime_integration_test.gd @@ -31,6 +31,11 @@ func _run() -> void: await process_frame _check(simulation_manager.npcs.size() == 6, "Baseline should create six NPCs") + var runtime_player := main_scene.get_node("Player") as Node3D + _check( + runtime_player.is_in_group("grass_interactors"), + "The real player transform should drive nearby grass response" + ) var inspector_label := ( main_scene.get_node("UI/NpcInspectorPanel/MarginContainer/NpcInspectorLabel") as Label ) @@ -60,6 +65,26 @@ func _run() -> void: "Runtime knowledge UI check should use a completed simulation transaction" ) var world_view := main_scene.get_node("WorldViewManager") + var all_npc_visuals_drive_grass := true + for npc_visual in world_view.active_npc_visuals.values(): + all_npc_visuals_drive_grass = ( + all_npc_visuals_drive_grass and (npc_visual as Node3D).is_in_group("grass_interactors") + ) + _check( + all_npc_visuals_drive_grass, + "Every active villager visual should drive local grass response" + ) + var grass_material := ( + main_scene.get_node("JajceWorld/TerrainRoot/Terrain3D/CozyGrassField").get( + "mesh_material_override" + ) + as ShaderMaterial + ) + var grass_interactor_count := int(grass_material.get_shader_parameter("interactor_count")) + _check( + grass_interactor_count in range(1, 9), + "Grass shader should receive a bounded set of real nearby actor transforms" + ) var contributor_visual: Node3D = world_view.active_npc_visuals[contributor.id] var witness_visual: Node3D = world_view.active_npc_visuals[witness.id] var listener_visual: Node3D = world_view.active_npc_visuals[listener.id] diff --git a/tests/jajce_world_scaffold_test.gd b/tests/jajce_world_scaffold_test.gd index 2ea03b7..cc79361 100644 --- a/tests/jajce_world_scaffold_test.gd +++ b/tests/jajce_world_scaffold_test.gd @@ -63,10 +63,48 @@ func _run() -> void: _check(world.has_node("WaterRoot"), "JajceWorld should expose WaterRoot") _check(world.has_node("VillageRoot"), "JajceWorld should expose VillageRoot") _check(world.has_node("FoliageRoot"), "JajceWorld should expose FoliageRoot") + _check(world.has_node("WildlifeRoot"), "JajceWorld should expose ambient WildlifeRoot") _check( world.get_node("FoliageRoot").get_child_count() >= 10, "JajceWorld should include restrained authored foliage clusters" ) + var conifer_count := 0 + for foliage in world.get_node("FoliageRoot").get_children(): + if foliage.name.begins_with("Conifer_"): + conifer_count += 1 + _check(conifer_count >= 6, "Citadel and river edges should include conifer silhouettes") + _check( + world.get_node("WildlifeRoot").get_child_count() >= 3, + "Village center should include a few oversized ambient butterflies" + ) + var grass_field := world.get_node("TerrainRoot/Terrain3D/CozyGrassField") + var grass_material := grass_field.get("mesh_material_override") as ShaderMaterial + var grass_process := grass_field.get("process_material") as ShaderMaterial + _check( + int(grass_field.get("particle_count")) in range(9000, 15001), + "Camera-local Terrain3D grass should stay inside its bounded particle budget" + ) + _check( + ( + grass_material != null + and grass_material.shader == load("res://world/jajce/materials/cozy_grass.gdshader") + ), + "Terrain grass should use the cozy wind-and-interaction shader" + ) + var grass_max_scale := Vector3.ZERO + var grass_clod_boost := INF + if grass_process != null: + grass_max_scale = grass_process.get_shader_parameter("max_scale") as Vector3 + grass_clod_boost = float(grass_process.get_shader_parameter("clod_scale_boost")) + _check( + grass_process != null and grass_max_scale.y <= 0.5 and grass_clod_boost <= 0.2, + "Grass placement should stay short enough to preserve villagers and paths" + ) + var grass_controller := world.get_node("TerrainRoot/GrassInteractionController") + _check( + grass_controller.get("grass_material") == grass_material, + "Grass interaction should update the same material rendered by Terrain3D particles" + ) _check( ( not world.has_node("WindStrokes_Valley") @@ -156,7 +194,15 @@ func _run() -> void: ) _check(tree_wind_is_bounded, "Tree wind should stay within the restrained cozy-motion budget") _check(world.has_node("FortressBlockout"), "JajceWorld should include the fortress landmark") + _check( + world.has_node("FortressBlockout/Keep/CitadelKeep"), + "Fortress silhouette should expose a distinct summit citadel" + ) _check(world.has_node("WaterRoot/WaterfallBody"), "WaterRoot should include a waterfall drop") + _check( + world.has_node("WaterRoot/WaterfallBody/RockFrame"), + "Waterfall should be framed by the rocky river threshold" + ) _check( world.has_node("WaterRoot/WaterfallMist"), "WaterRoot should include waterfall mist particles" @@ -167,10 +213,19 @@ func _run() -> void: "Waterfall mist particles should have visible billboard geometry" ) _check( - waterfall_mist.position.distance_to(Vector3(25.0, 0.8, -24.0)) < 0.01, + waterfall_mist.position.distance_to(Vector3(25.0, 2.05, -24.0)) < 0.01, "Waterfall mist should stay at the waterfall base instead of obscuring the village" ) _check(world.has_node("WaterRoot/RiverSurface"), "WaterRoot should include a river surface") + var river_ribbon := world.get_node("WaterRoot/RiverSurface/RiverRibbon") as MeshInstance3D + _check( + river_ribbon.mesh is ArrayMesh, + "River should build a lightweight ribbon from the current Terrain3D surface" + ) + _check( + world.has_node("WaterRoot/RiverSurface/PlungePool"), + "River should widen into a readable plunge pool beneath the waterfall" + ) var environment: Environment = ( (world.get_node("WorldEnvironment") as WorldEnvironment).environment ) @@ -190,6 +245,11 @@ func _run() -> void: world.has_node("VillageRoot/Mill") and world.has_node("VillageRoot/Bridge"), "VillageRoot should include the mill and bridge blockouts" ) + var house_count := 0 + for village_child in world.get_node("VillageRoot").get_children(): + if village_child.name.begins_with("House_"): + house_count += 1 + _check(house_count >= 8, "Roofs should form a small residential amphitheater below the citadel") var smoke_count := 0 var smoke_is_translucent := true var smoke_follows_wind := true diff --git a/tests/simulation_population_view_test.gd b/tests/simulation_population_view_test.gd new file mode 100644 index 0000000..eeeb79c --- /dev/null +++ b/tests/simulation_population_view_test.gd @@ -0,0 +1,77 @@ +extends SceneTree + +const RelationshipSystemScript := preload("res://simulation/relationships/RelationshipSystem.gd") + +var failures: Array[String] = [] + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var first := _create_npc(0, "First") + var second := _create_npc(1, "Second") + var observer := _create_npc(2, "Observer") + first.is_starving = true + var npcs: Array[SimNPC] = [first, second, observer] + var view := SimulationPopulationView.new(npcs) + _check(view.living_count() == 3, "The initial view should index every living NPC") + _check(view.starving_count() == 1, "The initial view should index the starving NPC once") + _check(view.get_starving(first.id) == first, "The starving lookup should preserve identity") + + second.is_starving = true + view.refresh_npc(second) + _check(view.starving_count() == 2, "Refreshing should add a newly starving NPC") + first.die_from_starvation() + view.refresh_npc(first) + _check( + view.get_living(first.id) == null and view.get_starving(first.id) == null, + "Refreshing should remove a dead NPC from both indexes" + ) + _check(view.get_any(first.id) == first, "The identity index should retain a dead NPC record") + + var relationships := RelationshipSystemScript.new() + var records: Array[RelationshipStateRecord] = [ + RelationshipStateRecord.create(observer.id, first.id, 0.8, 0.9), + RelationshipStateRecord.create(observer.id, second.id, 0.7, 0.7), + ] + relationships.restore(records) + _check( + relationships.get_trusted_starving_subject(observer.id, npcs, view) == second, + "The relationship query should ignore the stronger dead subject through the shared view" + ) + _check( + relationships.get_most_familiar_living(observer.id, npcs, view) == second, + "The living-subject query should use the same refreshed view" + ) + + first.is_dead = false + first.is_starving = true + view.refresh_npc(first) + _check( + relationships.get_trusted_starving_subject(observer.id, npcs, view) == first, + "A refreshed living subject should regain eligibility without rebuilding the view" + ) + _finish() + + +func _create_npc(npc_id: int, npc_name: String) -> SimNPC: + var random_source := RandomNumberGenerator.new() + random_source.seed = 7000 + npc_id + return SimNPC.new(npc_id, npc_name, SimulationIds.PROFESSION_FARMER, 5.0, 5.0, random_source) + + +func _check(condition: bool, message: String) -> void: + if not condition: + failures.append(message) + + +func _finish() -> void: + if failures.is_empty(): + print("[TEST] Simulation population view passed: refresh -> shared stable-ID queries") + quit(0) + return + for failure in failures: + push_error("[TEST] " + failure) + quit(1) diff --git a/tests/simulation_population_view_test.gd.uid b/tests/simulation_population_view_test.gd.uid new file mode 100644 index 0000000..0f9445c --- /dev/null +++ b/tests/simulation_population_view_test.gd.uid @@ -0,0 +1 @@ +uid://ervmgkik5q2x diff --git a/tools/capture_jajce_lookdev.gd b/tools/capture_jajce_lookdev.gd index 5a894f0..399c16d 100644 --- a/tools/capture_jajce_lookdev.gd +++ b/tools/capture_jajce_lookdev.gd @@ -1,7 +1,7 @@ extends SceneTree -const OUTPUT_PATH := "res://docs/baselines/jajce_lookdev_01.png" -const SCENE_PATH := "res://world/jajce/JajceLookdev.tscn" +const DEFAULT_OUTPUT_PATH := "res://docs/baselines/jajce_lookdev_01.png" +const DEFAULT_SCENE_PATH := "res://world/jajce/JajceLookdev.tscn" const CAPTURE_SIZE := Vector2i(1280, 720) const WARMUP_FRAMES := 90 @@ -12,15 +12,18 @@ func _initialize() -> void: func _run() -> void: root.size = CAPTURE_SIZE - var scene := load(SCENE_PATH) as PackedScene + var scene_path := _get_argument_value("--scene=", DEFAULT_SCENE_PATH) + var output_path := _get_argument_value("--output=", DEFAULT_OUTPUT_PATH) + var scene := load(scene_path) as PackedScene if scene == null: - push_error("Could not load %s" % SCENE_PATH) + push_error("Could not load %s" % scene_path) quit(1) return var lookdev := scene.instantiate() as Node3D root.add_child(lookdev) await process_frame + _stabilize_lookdev(lookdev) for _frame in WARMUP_FRAMES: await process_frame @@ -36,16 +39,40 @@ func _run() -> void: quit(1) return - var error := image.save_png(OUTPUT_PATH) + var error := image.save_png(output_path) if error != OK: - push_error("Could not save %s: %s" % [OUTPUT_PATH, error_string(error)]) + push_error("Could not save %s: %s" % [output_path, error_string(error)]) quit(1) return - print("[TOOL] Saved %s | %s" % [OUTPUT_PATH, analysis]) + print("[TOOL] Saved %s | %s" % [output_path, analysis]) quit(0) +func _stabilize_lookdev(lookdev: Node3D) -> void: + var camera := root.get_camera_3d() + if camera != null: + camera.set_process(false) + var simulation_manager := lookdev.get_node_or_null("SimulationManager") + if simulation_manager != null: + simulation_manager.set_process(false) + var day_night_cycle := lookdev.get_node_or_null("JajceWorld/DayNightCycle") + if day_night_cycle == null: + return + day_night_cycle.set_process(false) + day_night_cycle.call("_apply_light_rotation", 0.4) + day_night_cycle.call("_apply_environment", 0.4) + + +func _get_argument_value(prefix: String, default_value: String) -> String: + for argument in OS.get_cmdline_user_args(): + if argument.begins_with(prefix): + var value := argument.trim_prefix(prefix) + if not value.is_empty(): + return value + return default_value + + func _analyze_image(image: Image) -> Dictionary: var min_luma := INF var max_luma := -INF diff --git a/world/jajce/BridgeBlockout.tscn b/world/jajce/BridgeBlockout.tscn index 4042bf7..17b2575 100644 --- a/world/jajce/BridgeBlockout.tscn +++ b/world/jajce/BridgeBlockout.tscn @@ -1,33 +1,83 @@ -[gd_scene load_steps=5 format=3] +[gd_scene load_steps=9 format=3] [sub_resource type="StandardMaterial3D" id="Material_deck"] -albedo_color = Color(0.62, 0.55, 0.44, 1) -roughness = 0.8 +albedo_color = Color(0.61, 0.53, 0.42, 1) +roughness = 0.94 [sub_resource type="BoxMesh" id="Mesh_deck"] material = SubResource("Material_deck") -size = Vector3(8, 0.2, 3) +size = Vector3(15.2, 0.32, 3.15) -[sub_resource type="StandardMaterial3D" id="Material_support"] -albedo_color = Color(0.45, 0.42, 0.38, 1) -roughness = 0.85 +[sub_resource type="StandardMaterial3D" id="Material_stone"] +albedo_color = Color(0.48, 0.47, 0.41, 1) +roughness = 0.98 -[sub_resource type="CylinderMesh" id="Mesh_support"] -material = SubResource("Material_support") -top_radius = 0.2 -bottom_radius = 0.2 -height = 1.0 +[sub_resource type="BoxMesh" id="Mesh_abutment"] +material = SubResource("Material_stone") +size = Vector3(2.25, 1.45, 4.2) + +[sub_resource type="BoxMesh" id="Mesh_pier"] +material = SubResource("Material_stone") +size = Vector3(1.05, 1.65, 3.7) + +[sub_resource type="StandardMaterial3D" id="Material_rail"] +albedo_color = Color(0.2, 0.115, 0.055, 1) +roughness = 0.92 + +[sub_resource type="BoxMesh" id="Mesh_rail"] +material = SubResource("Material_rail") +size = Vector3(15.0, 0.16, 0.16) + +[sub_resource type="BoxMesh" id="Mesh_post"] +material = SubResource("Material_rail") +size = Vector3(0.18, 1.25, 0.18) [node name="BridgeBlockout" type="Node3D"] [node name="Deck" type="MeshInstance3D" parent="."] -position = Vector3(0, 0.6, 0) +position = Vector3(0, 1.08, 0) mesh = SubResource("Mesh_deck") -[node name="SupportL" type="MeshInstance3D" parent="."] -position = Vector3(-3.2, 0, 0) -mesh = SubResource("Mesh_support") +[node name="WestAbutment" type="MeshInstance3D" parent="."] +position = Vector3(-6.75, 0.15, 0) +mesh = SubResource("Mesh_abutment") -[node name="SupportR" type="MeshInstance3D" parent="."] -position = Vector3(3.2, 0, 0) -mesh = SubResource("Mesh_support") +[node name="EastAbutment" type="MeshInstance3D" parent="."] +position = Vector3(6.75, 0.15, 0) +mesh = SubResource("Mesh_abutment") + +[node name="CenterPier" type="MeshInstance3D" parent="."] +position = Vector3(0, 0.05, 0) +mesh = SubResource("Mesh_pier") + +[node name="NorthRail" type="MeshInstance3D" parent="."] +position = Vector3(0, 1.76, -1.45) +mesh = SubResource("Mesh_rail") + +[node name="SouthRail" type="MeshInstance3D" parent="."] +position = Vector3(0, 1.76, 1.45) +mesh = SubResource("Mesh_rail") + +[node name="PostNorthWest" type="MeshInstance3D" parent="."] +position = Vector3(-6.1, 1.48, -1.45) +mesh = SubResource("Mesh_post") + +[node name="PostNorthCenter" type="MeshInstance3D" parent="."] +position = Vector3(0, 1.48, -1.45) +mesh = SubResource("Mesh_post") + +[node name="PostNorthEast" type="MeshInstance3D" parent="."] +position = Vector3(6.1, 1.48, -1.45) +mesh = SubResource("Mesh_post") + +[node name="PostSouthWest" type="MeshInstance3D" parent="."] +position = Vector3(-6.1, 1.48, 1.45) +mesh = SubResource("Mesh_post") + +[node name="PostSouthCenter" type="MeshInstance3D" parent="."] +position = Vector3(0, 1.48, 1.45) +mesh = SubResource("Mesh_post") + +[node name="PostSouthEast" type="MeshInstance3D" parent="."] +position = Vector3(6.1, 1.48, 1.45) +mesh = SubResource("Mesh_post") diff --git a/world/jajce/CozyButterfly.gd b/world/jajce/CozyButterfly.gd new file mode 100644 index 0000000..6d4fcf7 --- /dev/null +++ b/world/jajce/CozyButterfly.gd @@ -0,0 +1,34 @@ +extends Node3D + +@export var orbit_radius := 1.2 +@export var orbit_speed := 0.45 +@export var bob_height := 0.35 +@export var phase := 0.0 + +@onready var left_wing: MeshInstance3D = $LeftWing +@onready var right_wing: MeshInstance3D = $RightWing + +var _center := Vector3.ZERO +var _elapsed := 0.0 + + +func _ready() -> void: + _center = position + _elapsed = phase + + +func _process(delta: float) -> void: + _elapsed += delta + var angle := _elapsed * orbit_speed + position = ( + _center + + Vector3( + cos(angle) * orbit_radius, + sin(_elapsed * 1.7) * bob_height, + sin(angle) * orbit_radius * 0.65 + ) + ) + rotation.y = -angle + PI * 0.5 + var flap := 22.0 + sin(_elapsed * 10.0) * 34.0 + left_wing.rotation_degrees.z = flap + right_wing.rotation_degrees.z = -flap diff --git a/world/jajce/CozyButterfly.gd.uid b/world/jajce/CozyButterfly.gd.uid new file mode 100644 index 0000000..38d31cd --- /dev/null +++ b/world/jajce/CozyButterfly.gd.uid @@ -0,0 +1 @@ +uid://di3mr7j4bdna3 diff --git a/world/jajce/CozyButterfly.tscn b/world/jajce/CozyButterfly.tscn new file mode 100644 index 0000000..8f1bb82 --- /dev/null +++ b/world/jajce/CozyButterfly.tscn @@ -0,0 +1,45 @@ +[gd_scene load_steps=6 format=3] + +[ext_resource type="Script" path="res://world/jajce/CozyButterfly.gd" id="1_script"] + +[sub_resource type="StandardMaterial3D" id="Material_body"] +albedo_color = Color(0.18, 0.1, 0.06, 1) +roughness = 0.85 + +[sub_resource type="CapsuleMesh" id="Mesh_body"] +material = SubResource("Material_body") +radius = 0.08 +height = 0.42 +radial_segments = 8 +rings = 3 + +[sub_resource type="StandardMaterial3D" id="Material_wing"] +albedo_color = Color(0.94, 0.53, 0.19, 1) +roughness = 0.72 +emission_enabled = true +emission = Color(0.32, 0.08, 0.01, 1) +emission_energy_multiplier = 0.18 + +[sub_resource type="SphereMesh" id="Mesh_wing"] +material = SubResource("Material_wing") +radius = 0.42 +height = 0.7 +radial_segments = 12 +rings = 6 + +[node name="CozyButterfly" type="Node3D"] +script = ExtResource("1_script") + +[node name="Body" type="MeshInstance3D" parent="."] +rotation_degrees = Vector3(90, 0, 0) +mesh = SubResource("Mesh_body") + +[node name="LeftWing" type="MeshInstance3D" parent="."] +position = Vector3(-0.3, 0, 0) +scale = Vector3(1, 0.12, 0.72) +mesh = SubResource("Mesh_wing") + +[node name="RightWing" type="MeshInstance3D" parent="."] +position = Vector3(0.3, 0, 0) +scale = Vector3(1, 0.12, 0.72) +mesh = SubResource("Mesh_wing") diff --git a/world/jajce/FortressBlockout.tscn b/world/jajce/FortressBlockout.tscn index 20ac413..c03cc4c 100644 --- a/world/jajce/FortressBlockout.tscn +++ b/world/jajce/FortressBlockout.tscn @@ -1,119 +1,238 @@ -[gd_scene load_steps=12 format=3] +[gd_scene load_steps=19 format=3] -[sub_resource type="StandardMaterial3D" id="Material_keep"] -albedo_color = Color(0.45, 0.42, 0.38, 1) -roughness = 0.85 +[sub_resource type="StandardMaterial3D" id="Material_rock"] +albedo_color = Color(0.25, 0.27, 0.24, 1) +roughness = 1.0 + +[sub_resource type="CylinderMesh" id="Mesh_rock_plinth"] +material = SubResource("Material_rock") +top_radius = 8.4 +bottom_radius = 10.2 +height = 3.2 +radial_segments = 9 + +[sub_resource type="StandardMaterial3D" id="Material_stone"] +albedo_color = Color(0.47, 0.47, 0.43, 1) +roughness = 0.96 + +[sub_resource type="StandardMaterial3D" id="Material_stone_light"] +albedo_color = Color(0.57, 0.56, 0.51, 1) +roughness = 0.95 [sub_resource type="BoxMesh" id="Mesh_keep"] -material = SubResource("Material_keep") -size = Vector3(7, 6, 9) +material = SubResource("Material_stone_light") +size = Vector3(7.4, 7.8, 8.6) -[sub_resource type="StandardMaterial3D" id="Material_parapet"] -albedo_color = Color(0.42, 0.39, 0.35, 1) -roughness = 0.85 +[sub_resource type="BoxMesh" id="Mesh_wall_long"] +material = SubResource("Material_stone") +size = Vector3(13.8, 3.3, 1.15) -[sub_resource type="BoxMesh" id="Mesh_parapet"] -material = SubResource("Material_parapet") -size = Vector3(6.6, 1.5, 7.8) +[sub_resource type="BoxMesh" id="Mesh_wall_short"] +material = SubResource("Material_stone") +size = Vector3(1.15, 3.3, 11.8) -[sub_resource type="StandardMaterial3D" id="Material_turret"] -albedo_color = Color(0.45, 0.42, 0.38, 1) -roughness = 0.85 +[sub_resource type="CylinderMesh" id="Mesh_tower"] +material = SubResource("Material_stone_light") +top_radius = 1.65 +bottom_radius = 1.95 +height = 6.0 +radial_segments = 10 -[sub_resource type="CylinderMesh" id="Mesh_turret"] -material = SubResource("Material_turret") -top_radius = 0.6 -bottom_radius = 0.7 -height = 4.0 +[sub_resource type="CylinderMesh" id="Mesh_tower_cap"] +material = SubResource("Material_stone") +top_radius = 1.9 +bottom_radius = 1.9 +height = 0.65 +radial_segments = 10 [sub_resource type="BoxMesh" id="Mesh_crenellation"] -material = SubResource("Material_parapet") -size = Vector3(0.55, 0.9, 0.7) +material = SubResource("Material_stone_light") +size = Vector3(0.72, 0.92, 0.82) -[sub_resource type="StandardMaterial3D" id="Material_pole"] -albedo_color = Color(0.24, 0.13, 0.07, 1) -roughness = 0.82 +[sub_resource type="BoxMesh" id="Mesh_gate_post"] +material = SubResource("Material_stone_light") +size = Vector3(1.25, 3.7, 1.7) + +[sub_resource type="BoxMesh" id="Mesh_gate_lintel"] +material = SubResource("Material_stone_light") +size = Vector3(4.6, 1.05, 1.7) + +[sub_resource type="StandardMaterial3D" id="Material_gate"] +albedo_color = Color(0.18, 0.11, 0.065, 1) +roughness = 0.9 + +[sub_resource type="BoxMesh" id="Mesh_gate"] +material = SubResource("Material_gate") +size = Vector3(2.2, 2.8, 0.22) [sub_resource type="CylinderMesh" id="Mesh_pole"] -material = SubResource("Material_pole") -top_radius = 0.08 -bottom_radius = 0.1 -height = 3.5 +material = SubResource("Material_gate") +top_radius = 0.07 +bottom_radius = 0.09 +height = 4.2 +radial_segments = 8 [sub_resource type="StandardMaterial3D" id="Material_banner"] -albedo_color = Color(0.68, 0.12, 0.1, 1) -roughness = 0.65 +albedo_color = Color(0.58, 0.1, 0.08, 1) +roughness = 0.76 [sub_resource type="BoxMesh" id="Mesh_banner"] material = SubResource("Material_banner") -size = Vector3(0.14, 2.0, 1.25) +size = Vector3(0.12, 2.2, 1.35) + +[sub_resource type="StandardMaterial3D" id="Material_window_slit"] +albedo_color = Color(0.12, 0.14, 0.13, 1) +roughness = 0.98 + +[sub_resource type="BoxMesh" id="Mesh_window_slit"] +material = SubResource("Material_window_slit") +size = Vector3(0.38, 1.25, 0.14) [node name="FortressBlockout" type="Node3D"] -[node name="Keep" type="MeshInstance3D" parent="."] -position = Vector3(0, 3, 0) +[node name="RockyCitadelHill" type="MeshInstance3D" parent="."] +position = Vector3(0, 1.35, 0) +scale = Vector3(1.22, 1, 1) +mesh = SubResource("Mesh_rock_plinth") + +[node name="CitadelKeep" type="MeshInstance3D" parent="."] +position = Vector3(-0.8, 7.05, 0.35) +rotation_degrees = Vector3(0, -4, 0) mesh = SubResource("Mesh_keep") -[node name="Parapet" type="MeshInstance3D" parent="."] -position = Vector3(0, 6.75, 0) -mesh = SubResource("Mesh_parapet") +[node name="KeepWindowSouthLeft" type="MeshInstance3D" parent="."] +position = Vector3(-2.55, 7.0, 4.72) +rotation_degrees = Vector3(0, -4, 0) +mesh = SubResource("Mesh_window_slit") -[node name="Crenellation_01" type="MeshInstance3D" parent="."] -position = Vector3(-2.8, 7.7, -3.1) +[node name="KeepWindowSouthRight" type="MeshInstance3D" parent="."] +position = Vector3(0.85, 7.0, 4.72) +rotation_degrees = Vector3(0, -4, 0) +mesh = SubResource("Mesh_window_slit") + +[node name="KeepWindowEast" type="MeshInstance3D" parent="."] +position = Vector3(2.95, 7.45, 1.45) +rotation_degrees = Vector3(0, 86, 0) +mesh = SubResource("Mesh_window_slit") + +[node name="NorthRampart" type="MeshInstance3D" parent="."] +position = Vector3(0, 4.05, -5.55) +rotation_degrees = Vector3(0, -2, 0) +mesh = SubResource("Mesh_wall_long") + +[node name="SouthRampart" type="MeshInstance3D" parent="."] +position = Vector3(-1.1, 4.0, 5.75) +rotation_degrees = Vector3(0, 4, 0) +mesh = SubResource("Mesh_wall_long") + +[node name="WestRampart" type="MeshInstance3D" parent="."] +position = Vector3(-6.55, 4.0, 0) +mesh = SubResource("Mesh_wall_short") + +[node name="EastRampart" type="MeshInstance3D" parent="."] +position = Vector3(6.55, 4.0, 0) +mesh = SubResource("Mesh_wall_short") + +[node name="DescendingWallEast" type="MeshInstance3D" parent="."] +position = Vector3(9.5, 2.8, 2.7) +rotation_degrees = Vector3(0, 23, -7) +scale = Vector3(0.65, 0.7, 1) +mesh = SubResource("Mesh_wall_short") + +[node name="DescendingWallSouth" type="MeshInstance3D" parent="."] +position = Vector3(4.8, 2.7, 8.1) +rotation_degrees = Vector3(0, 38, 5) +scale = Vector3(0.72, 0.7, 1) +mesh = SubResource("Mesh_wall_short") + +[node name="TowerNorthWest" type="MeshInstance3D" parent="."] +position = Vector3(-6.5, 4.2, -5.5) +mesh = SubResource("Mesh_tower") + +[node name="TowerNorthWestCap" type="MeshInstance3D" parent="."] +position = Vector3(-6.5, 7.28, -5.5) +mesh = SubResource("Mesh_tower_cap") + +[node name="TowerNorthEast" type="MeshInstance3D" parent="."] +position = Vector3(6.5, 4.2, -5.5) +mesh = SubResource("Mesh_tower") + +[node name="TowerNorthEastCap" type="MeshInstance3D" parent="."] +position = Vector3(6.5, 7.28, -5.5) +mesh = SubResource("Mesh_tower_cap") + +[node name="TowerSouthWest" type="MeshInstance3D" parent="."] +position = Vector3(-6.5, 4.2, 5.5) +mesh = SubResource("Mesh_tower") + +[node name="TowerSouthWestCap" type="MeshInstance3D" parent="."] +position = Vector3(-6.5, 7.28, 5.5) +mesh = SubResource("Mesh_tower_cap") + +[node name="GatePostLeft" type="MeshInstance3D" parent="."] +position = Vector3(4.45, 4.1, 5.62) +mesh = SubResource("Mesh_gate_post") + +[node name="GatePostRight" type="MeshInstance3D" parent="."] +position = Vector3(7.15, 4.1, 5.62) +mesh = SubResource("Mesh_gate_post") + +[node name="GateLintel" type="MeshInstance3D" parent="."] +position = Vector3(5.8, 6.35, 5.62) +mesh = SubResource("Mesh_gate_lintel") + +[node name="GateDoor" type="MeshInstance3D" parent="."] +position = Vector3(5.8, 3.65, 6.22) +mesh = SubResource("Mesh_gate") + +[node name="CrenellationNorth01" type="MeshInstance3D" parent="."] +position = Vector3(-5.2, 6.12, -5.55) mesh = SubResource("Mesh_crenellation") -[node name="Crenellation_02" type="MeshInstance3D" parent="."] -position = Vector3(-1.4, 7.7, -3.1) +[node name="CrenellationNorth02" type="MeshInstance3D" parent="."] +position = Vector3(-2.6, 6.12, -5.55) mesh = SubResource("Mesh_crenellation") -[node name="Crenellation_03" type="MeshInstance3D" parent="."] -position = Vector3(0, 7.7, -3.1) +[node name="CrenellationNorth03" type="MeshInstance3D" parent="."] +position = Vector3(0, 6.12, -5.55) mesh = SubResource("Mesh_crenellation") -[node name="Crenellation_04" type="MeshInstance3D" parent="."] -position = Vector3(1.4, 7.7, -3.1) +[node name="CrenellationNorth04" type="MeshInstance3D" parent="."] +position = Vector3(2.6, 6.12, -5.55) mesh = SubResource("Mesh_crenellation") -[node name="Crenellation_05" type="MeshInstance3D" parent="."] -position = Vector3(2.8, 7.7, -3.1) +[node name="CrenellationNorth05" type="MeshInstance3D" parent="."] +position = Vector3(5.2, 6.12, -5.55) mesh = SubResource("Mesh_crenellation") -[node name="Crenellation_06" type="MeshInstance3D" parent="."] -position = Vector3(-2.8, 7.7, 3.1) +[node name="CrenellationSouth01" type="MeshInstance3D" parent="."] +position = Vector3(-5.2, 6.08, 5.75) mesh = SubResource("Mesh_crenellation") -[node name="Crenellation_07" type="MeshInstance3D" parent="."] -position = Vector3(-1.4, 7.7, 3.1) +[node name="CrenellationSouth02" type="MeshInstance3D" parent="."] +position = Vector3(-2.6, 6.08, 5.75) mesh = SubResource("Mesh_crenellation") -[node name="Crenellation_08" type="MeshInstance3D" parent="."] -position = Vector3(0, 7.7, 3.1) +[node name="CrenellationSouth03" type="MeshInstance3D" parent="."] +position = Vector3(0, 6.08, 5.75) mesh = SubResource("Mesh_crenellation") -[node name="Crenellation_09" type="MeshInstance3D" parent="."] -position = Vector3(1.4, 7.7, 3.1) +[node name="KeepCrenellationLeft" type="MeshInstance3D" parent="."] +position = Vector3(-3.5, 11.35, 3.8) mesh = SubResource("Mesh_crenellation") -[node name="Crenellation_10" type="MeshInstance3D" parent="."] -position = Vector3(2.8, 7.7, 3.1) +[node name="KeepCrenellationCenter" type="MeshInstance3D" parent="."] +position = Vector3(-0.8, 11.35, 3.8) mesh = SubResource("Mesh_crenellation") -[node name="Turret_A" type="MeshInstance3D" parent="."] -position = Vector3(-3.2, 2.0, -4) -mesh = SubResource("Mesh_turret") - -[node name="Turret_B" type="MeshInstance3D" parent="."] -position = Vector3(3.2, 2.0, -4) -mesh = SubResource("Mesh_turret") - -[node name="Turret_C" type="MeshInstance3D" parent="."] -position = Vector3(3.2, 2.0, 4) -mesh = SubResource("Mesh_turret") +[node name="KeepCrenellationRight" type="MeshInstance3D" parent="."] +position = Vector3(1.9, 11.35, 3.8) +mesh = SubResource("Mesh_crenellation") [node name="RidgeBannerPole" type="MeshInstance3D" parent="."] -position = Vector3(0, 8.4, 3.8) +position = Vector3(-0.8, 13.1, 3.45) mesh = SubResource("Mesh_pole") [node name="RidgeBanner" type="MeshInstance3D" parent="."] -position = Vector3(0, 9.2, 4.45) +position = Vector3(-0.8, 13.75, 4.12) mesh = SubResource("Mesh_banner") diff --git a/world/jajce/GrassInteractionController.gd b/world/jajce/GrassInteractionController.gd new file mode 100644 index 0000000..6ee53d8 --- /dev/null +++ b/world/jajce/GrassInteractionController.gd @@ -0,0 +1,51 @@ +extends Node + +const SHADER_INTERACTOR_LIMIT := 8 + +@export var grass_material: ShaderMaterial +@export_range(1, SHADER_INTERACTOR_LIMIT, 1) var max_interactors := 8 +@export_range(0.02, 0.5, 0.01) var update_interval := 0.08 + +var _elapsed := 0.0 + + +func _ready() -> void: + _refresh_interactors() + + +func _process(delta: float) -> void: + _elapsed += delta + if _elapsed < update_interval: + return + _elapsed = 0.0 + _refresh_interactors() + + +func _refresh_interactors() -> void: + if grass_material == null: + return + var camera := get_viewport().get_camera_3d() + var origin := camera.global_position if camera != null else Vector3.ZERO + var nearest: Array[Node3D] = [] + for value in get_tree().get_nodes_in_group("grass_interactors"): + var candidate := value as Node3D + if candidate == null: + continue + var insert_at := nearest.size() + var candidate_distance := origin.distance_squared_to(candidate.global_position) + for index in nearest.size(): + if candidate_distance < origin.distance_squared_to(nearest[index].global_position): + insert_at = index + break + nearest.insert(insert_at, candidate) + if nearest.size() > max_interactors: + nearest.pop_back() + + var positions := PackedVector3Array() + positions.resize(SHADER_INTERACTOR_LIMIT) + for index in SHADER_INTERACTOR_LIMIT: + positions[index] = Vector3(0.0, -1000.0, 0.0) + for index in nearest.size(): + positions[index] = nearest[index].global_position + grass_material.set_shader_parameter("interactor_count", nearest.size()) + grass_material.set_shader_parameter("interactor_positions", positions) diff --git a/world/jajce/GrassInteractionController.gd.uid b/world/jajce/GrassInteractionController.gd.uid new file mode 100644 index 0000000..7c0d942 --- /dev/null +++ b/world/jajce/GrassInteractionController.gd.uid @@ -0,0 +1 @@ +uid://3py86vi5qjwy diff --git a/world/jajce/JajceWorld.tscn b/world/jajce/JajceWorld.tscn index 55a7e20..16c470b 100644 --- a/world/jajce/JajceWorld.tscn +++ b/world/jajce/JajceWorld.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=50 format=3] +[gd_scene load_steps=57 format=3] [ext_resource type="Terrain3DAssets" path="res://terrain/jajce/assets.tres" id="1_assets"] [ext_resource type="PackedScene" path="res://world/resource_nodes/ResourceNode.tscn" id="2_resource"] @@ -18,6 +18,13 @@ [ext_resource type="NavigationMesh" path="res://world/jajce/JajceNavigationMesh.tres" id="16_nav"] [ext_resource type="PackedScene" path="res://world/jajce/vfx/WindGustField.tscn" id="17_wind_gust"] [ext_resource type="Script" path="res://world/storage/PantryStockVisual.gd" id="18_pantry_visual"] +[ext_resource type="PackedScene" path="res://addons/terrain_3d/extras/particle_example/Terrain3DParticles.tscn" id="19_terrain_particles"] +[ext_resource type="Material" path="res://world/jajce/materials/cozy_grass_material.tres" id="20_grass_material"] +[ext_resource type="Script" path="res://world/jajce/GrassInteractionController.gd" id="21_grass_controller"] +[ext_resource type="PackedScene" path="res://assets/foliage/tree.glb" id="22_conifer"] +[ext_resource type="PackedScene" path="res://assets/foliage/tree-high.glb" id="23_conifer_high"] +[ext_resource type="PackedScene" path="res://world/jajce/CozyButterfly.tscn" id="24_butterfly"] +[ext_resource type="Material" path="res://world/jajce/materials/cozy_grass_process_material.tres" id="25_grass_process"] [sub_resource type="Terrain3DMaterial" id="Terrain3DMaterial_jajce"] _shader_parameters = { @@ -193,16 +200,16 @@ fog_height_density = 0.08 fog_aerial_perspective = 0.35 fog_sky_affect = 0.28 volumetric_fog_enabled = true -volumetric_fog_density = 0.006 -volumetric_fog_albedo = Color(0.94, 0.88, 0.75, 1) -volumetric_fog_emission = Color(0.1, 0.07, 0.04, 1) -volumetric_fog_emission_energy = 0.22 +volumetric_fog_density = 0.0035 +volumetric_fog_albedo = Color(0.82, 0.9, 0.82, 1) +volumetric_fog_emission = Color(0.06, 0.09, 0.07, 1) +volumetric_fog_emission_energy = 0.12 volumetric_fog_length = 96.0 volumetric_fog_detail_spread = 1.6 adjustment_enabled = true adjustment_brightness = 1.08 adjustment_contrast = 1.04 -adjustment_saturation = 1.25 +adjustment_saturation = 1.08 [sub_resource type="StandardMaterial3D" id="Material_guard"] albedo_color = Color(0.55, 0.45, 0.35, 1) @@ -246,10 +253,28 @@ collision_mask = 3 collision_mode = 1 top_level = true +[node name="CozyGrassField" parent="TerrainRoot/Terrain3D" instance=ExtResource("19_terrain_particles")] +instance_spacing = 0.703125 +cell_width = 24.0 +grid_width = 3 +rows = 34 +amount = 1156 +process_fixed_fps = 24 +process_material = ExtResource("25_grass_process") +shadow_mode = 0 +mesh_material_override = ExtResource("20_grass_material") +min_draw_distance = 36.0 +particle_count = 10404 + +[node name="GrassInteractionController" type="Node" parent="TerrainRoot"] +script = ExtResource("21_grass_controller") +grass_material = ExtResource("20_grass_material") + [node name="FortressBlockout" type="Node3D" parent="."] position = Vector3(-25, 7, 9) [node name="Keep" parent="FortressBlockout" instance=ExtResource("5_fortress")] +scale = Vector3(1.06, 1.06, 1.06) [node name="WaterRoot" type="Node3D" parent="."] @@ -263,22 +288,58 @@ position = Vector3(-25, 7, 9) [node name="House_01" parent="VillageRoot" instance=ExtResource("6_house")] position = Vector3(-15, 2, 7) +rotation_degrees = Vector3(0, 8, 0) [node name="Smoke" parent="VillageRoot/House_01" instance=ExtResource("12_smoke")] position = Vector3(0, 4.5, 0) [node name="House_02" parent="VillageRoot" instance=ExtResource("6_house")] position = Vector3(-8, 2, 2) +rotation_degrees = Vector3(0, -12, 0) +scale = Vector3(0.92, 0.92, 0.92) [node name="Smoke" parent="VillageRoot/House_02" instance=ExtResource("12_smoke")] position = Vector3(0, 4.5, 0) [node name="House_03" parent="VillageRoot" instance=ExtResource("6_house")] position = Vector3(-15, 2, -4) +rotation_degrees = Vector3(0, 18, 0) +scale = Vector3(0.88, 0.88, 0.88) [node name="Smoke" parent="VillageRoot/House_03" instance=ExtResource("12_smoke")] position = Vector3(0, 4.5, 0) +[node name="House_04" parent="VillageRoot" instance=ExtResource("6_house")] +position = Vector3(-19, 0, 15) +rotation_degrees = Vector3(0, -19, 0) +scale = Vector3(0.82, 0.82, 0.82) + +[node name="Smoke" parent="VillageRoot/House_04" instance=ExtResource("12_smoke")] +position = Vector3(0, 4.5, 0) + +[node name="House_05" parent="VillageRoot" instance=ExtResource("6_house")] +position = Vector3(-10, 0, 13) +rotation_degrees = Vector3(0, 15, 0) +scale = Vector3(0.76, 0.76, 0.76) + +[node name="Smoke" parent="VillageRoot/House_05" instance=ExtResource("12_smoke")] +position = Vector3(0, 4.5, 0) + +[node name="House_06" parent="VillageRoot" instance=ExtResource("6_house")] +position = Vector3(-7, 0, -13) +rotation_degrees = Vector3(0, 31, 0) +scale = Vector3(0.9, 0.9, 0.9) + +[node name="House_07" parent="VillageRoot" instance=ExtResource("6_house")] +position = Vector3(2, 0, 12) +rotation_degrees = Vector3(0, -24, 0) +scale = Vector3(0.8, 0.8, 0.8) + +[node name="House_08" parent="VillageRoot" instance=ExtResource("6_house")] +position = Vector3(-22, 0, -9) +rotation_degrees = Vector3(0, 36, 0) +scale = Vector3(0.78, 0.78, 0.78) + [node name="Mill" parent="VillageRoot" instance=ExtResource("7_mill")] position = Vector3(19, 2, 7) @@ -353,6 +414,62 @@ scale = Vector3(0.9, 0.9, 0.9) position = Vector3(31, 0, 14) scale = Vector3(1.25, 1.25, 1.25) +[node name="Conifer_Citadel_01" parent="FoliageRoot" instance=ExtResource("23_conifer_high")] +position = Vector3(-39, 0, 13) +rotation_degrees = Vector3(0, 18, 0) +scale = Vector3(2.4, 2.4, 2.4) + +[node name="Conifer_Citadel_02" parent="FoliageRoot" instance=ExtResource("22_conifer")] +position = Vector3(-34, 0, 22) +rotation_degrees = Vector3(0, -35, 0) +scale = Vector3(2.8, 2.8, 2.8) + +[node name="Conifer_Citadel_03" parent="FoliageRoot" instance=ExtResource("23_conifer_high")] +position = Vector3(-43, 0, 3) +rotation_degrees = Vector3(0, 62, 0) +scale = Vector3(2.15, 2.15, 2.15) + +[node name="Conifer_Falls_01" parent="FoliageRoot" instance=ExtResource("23_conifer_high")] +position = Vector3(36, 0, -17) +rotation_degrees = Vector3(0, -18, 0) +scale = Vector3(2.35, 2.35, 2.35) + +[node name="Conifer_Falls_02" parent="FoliageRoot" instance=ExtResource("22_conifer")] +position = Vector3(38, 0, -8) +rotation_degrees = Vector3(0, 28, 0) +scale = Vector3(2.7, 2.7, 2.7) + +[node name="Conifer_River_01" parent="FoliageRoot" instance=ExtResource("22_conifer")] +position = Vector3(38, 0, 20) +rotation_degrees = Vector3(0, -51, 0) +scale = Vector3(2.55, 2.55, 2.55) + +[node name="Conifer_South_01" parent="FoliageRoot" instance=ExtResource("23_conifer_high")] +position = Vector3(-21, 0, -29) +rotation_degrees = Vector3(0, 44, 0) +scale = Vector3(2.25, 2.25, 2.25) + +[node name="Conifer_South_02" parent="FoliageRoot" instance=ExtResource("22_conifer")] +position = Vector3(8, 0, -31) +rotation_degrees = Vector3(0, -20, 0) +scale = Vector3(2.65, 2.65, 2.65) + +[node name="WildlifeRoot" type="Node3D" parent="."] + +[node name="Butterfly_Meadow_01" parent="WildlifeRoot" instance=ExtResource("24_butterfly")] +position = Vector3(-3, 3.1, -5) +phase = 0.4 + +[node name="Butterfly_Houses_01" parent="WildlifeRoot" instance=ExtResource("24_butterfly")] +position = Vector3(-11, 4.2, 8) +scale = Vector3(0.9, 0.9, 0.9) +phase = 2.3 + +[node name="Butterfly_River_01" parent="WildlifeRoot" instance=ExtResource("24_butterfly")] +position = Vector3(10, 3.6, 5) +scale = Vector3(1.12, 1.12, 1.12) +phase = 4.4 + [node name="WorldObjects" type="Node3D" parent="."] [node name="ResourceNodes" type="Node3D" parent="WorldObjects"] diff --git a/world/jajce/StylizedHouse.tscn b/world/jajce/StylizedHouse.tscn index 54c5f91..f4df742 100644 --- a/world/jajce/StylizedHouse.tscn +++ b/world/jajce/StylizedHouse.tscn @@ -1,40 +1,116 @@ -[gd_scene load_steps=7 format=3] +[gd_scene load_steps=15 format=3] + +[sub_resource type="StandardMaterial3D" id="Material_foundation"] +albedo_color = Color(0.43, 0.42, 0.36, 1) +roughness = 0.98 + +[sub_resource type="BoxMesh" id="Mesh_foundation"] +material = SubResource("Material_foundation") +size = Vector3(4.35, 0.65, 5.35) [sub_resource type="StandardMaterial3D" id="Material_walls"] -albedo_color = Color(0.72, 0.65, 0.52, 1) -roughness = 0.85 +albedo_color = Color(0.78, 0.7, 0.56, 1) +roughness = 0.94 [sub_resource type="BoxMesh" id="Mesh_walls"] material = SubResource("Material_walls") -size = Vector3(4, 2.5, 5) +size = Vector3(4, 2.75, 5) [sub_resource type="StandardMaterial3D" id="Material_roof"] -albedo_color = Color(0.55, 0.30, 0.18, 1) -roughness = 0.75 +albedo_color = Color(0.48, 0.2, 0.12, 1) +roughness = 0.9 [sub_resource type="PrismMesh" id="Mesh_roof"] material = SubResource("Material_roof") -size = Vector3(4.2, 1.5, 5.2) +size = Vector3(4.65, 1.7, 5.65) left_to_right = 0.5 -[sub_resource type="StandardMaterial3D" id="Material_door"] -albedo_color = Color(0.20, 0.12, 0.06, 1) -roughness = 0.7 +[sub_resource type="StandardMaterial3D" id="Material_timber"] +albedo_color = Color(0.2, 0.105, 0.05, 1) +roughness = 0.92 + +[sub_resource type="BoxMesh" id="Mesh_beam_vertical"] +material = SubResource("Material_timber") +size = Vector3(0.17, 2.7, 0.16) + +[sub_resource type="BoxMesh" id="Mesh_beam_horizontal"] +material = SubResource("Material_timber") +size = Vector3(4.15, 0.16, 0.16) [sub_resource type="BoxMesh" id="Mesh_door"] -material = SubResource("Material_door") -size = Vector3(0.8, 1.4, 0.1) +material = SubResource("Material_timber") +size = Vector3(0.92, 1.65, 0.14) + +[sub_resource type="StandardMaterial3D" id="Material_window"] +albedo_color = Color(0.9, 0.62, 0.25, 1) +roughness = 0.55 +emission_enabled = true +emission = Color(0.72, 0.31, 0.08, 1) +emission_energy_multiplier = 0.35 + +[sub_resource type="BoxMesh" id="Mesh_window"] +material = SubResource("Material_window") +size = Vector3(0.72, 0.72, 0.12) + +[sub_resource type="BoxMesh" id="Mesh_window_cross_vertical"] +material = SubResource("Material_timber") +size = Vector3(0.08, 0.82, 0.08) + +[sub_resource type="BoxMesh" id="Mesh_window_cross_horizontal"] +material = SubResource("Material_timber") +size = Vector3(0.82, 0.08, 0.08) + +[sub_resource type="BoxMesh" id="Mesh_chimney"] +material = SubResource("Material_foundation") +size = Vector3(0.62, 1.65, 0.62) [node name="StylizedHouse" type="Node3D"] -[node name="Walls" type="MeshInstance3D" parent="."] -position = Vector3(0, 1.25, 0) +[node name="StoneFoundation" type="MeshInstance3D" parent="."] +position = Vector3(0, 0.32, 0) +mesh = SubResource("Mesh_foundation") + +[node name="PlasterWalls" type="MeshInstance3D" parent="."] +position = Vector3(0, 1.85, 0) mesh = SubResource("Mesh_walls") [node name="Roof" type="MeshInstance3D" parent="."] -position = Vector3(0, 3.25, 0) +position = Vector3(0, 4.08, 0) mesh = SubResource("Mesh_roof") +[node name="FrontBeamLeft" type="MeshInstance3D" parent="."] +position = Vector3(-1.78, 1.9, 2.56) +mesh = SubResource("Mesh_beam_vertical") + +[node name="FrontBeamRight" type="MeshInstance3D" parent="."] +position = Vector3(1.78, 1.9, 2.56) +mesh = SubResource("Mesh_beam_vertical") + +[node name="FrontBeamTop" type="MeshInstance3D" parent="."] +position = Vector3(0, 3.0, 2.56) +mesh = SubResource("Mesh_beam_horizontal") + +[node name="FrontBeamMiddle" type="MeshInstance3D" parent="."] +position = Vector3(0, 1.62, 2.56) +mesh = SubResource("Mesh_beam_horizontal") + [node name="Door" type="MeshInstance3D" parent="."] -position = Vector3(0, 1.0, 2.55) +position = Vector3(-0.72, 1.15, 2.59) mesh = SubResource("Mesh_door") + +[node name="Window" type="MeshInstance3D" parent="."] +position = Vector3(0.92, 2.1, 2.6) +mesh = SubResource("Mesh_window") + +[node name="WindowCrossVertical" type="MeshInstance3D" parent="."] +position = Vector3(0.92, 2.1, 2.69) +mesh = SubResource("Mesh_window_cross_vertical") + +[node name="WindowCrossHorizontal" type="MeshInstance3D" parent="."] +position = Vector3(0.92, 2.1, 2.69) +mesh = SubResource("Mesh_window_cross_horizontal") + +[node name="Chimney" type="MeshInstance3D" parent="."] +position = Vector3(1.25, 4.45, -0.65) +rotation_degrees = Vector3(0, 0, 2) +mesh = SubResource("Mesh_chimney") diff --git a/world/jajce/StylizedTree.gd b/world/jajce/StylizedTree.gd index c4cc7ac..f003451 100644 --- a/world/jajce/StylizedTree.gd +++ b/world/jajce/StylizedTree.gd @@ -7,9 +7,9 @@ enum CanopyVariant { } const COLORS := { - CanopyVariant.GREEN: Color(0.19, 0.38, 0.17), - CanopyVariant.DARK_GREEN: Color(0.12, 0.30, 0.12), - CanopyVariant.YELLOW_GREEN: Color(0.30, 0.42, 0.15), + CanopyVariant.GREEN: Color(0.18, 0.39, 0.16), + CanopyVariant.DARK_GREEN: Color(0.10, 0.28, 0.12), + CanopyVariant.YELLOW_GREEN: Color(0.34, 0.47, 0.16), } const WIND_SHADER := preload("res://world/jajce/materials/wind.gdshader") @@ -25,13 +25,14 @@ func _ready() -> void: var rng := RandomNumberGenerator.new() rng.seed = hash_seed var canopy_variant := rng.randi() % COLORS.size() as CanopyVariant - var canopy_radius := 1.0 + rng.randf() * 0.8 + var canopy_radius := 1.0 + rng.randf() * 0.55 var wind_phase := rng.randf_range(0.0, TAU) var trunk_mesh := CylinderMesh.new() trunk_mesh.top_radius = 0.22 trunk_mesh.bottom_radius = 0.32 - trunk_mesh.height = 2.8 + trunk_mesh.height = 3.4 + trunk_mesh.radial_segments = 9 var trunk_mat := StandardMaterial3D.new() trunk_mat.albedo_color = Color(0.27, 0.16, 0.09) @@ -41,24 +42,87 @@ func _ready() -> void: var trunk := MeshInstance3D.new() trunk.name = "Trunk" trunk.mesh = trunk_mesh - trunk.position = Vector3(0, 1.4, 0) + trunk.position = Vector3(0, 1.7, 0) add_child(trunk) - var canopy_mesh := SphereMesh.new() - canopy_mesh.radius = canopy_radius - canopy_mesh.height = 2.4 + _add_branch(Vector3(-0.18, 2.45, 0.0), Vector3(0.0, 0.0, 42.0), 1.45, trunk_mat) + _add_branch(Vector3(0.2, 2.6, 0.08), Vector3(24.0, 0.0, -38.0), 1.25, trunk_mat) - var canopy_mat := ShaderMaterial.new() - canopy_mat.shader = WIND_SHADER - canopy_mat.set_shader_parameter("albedo", COLORS[canopy_variant]) - canopy_mat.set_shader_parameter("roughness", 0.9) - canopy_mat.set_shader_parameter("wind_strength", WIND_STRENGTH) - canopy_mat.set_shader_parameter("wind_speed", WIND_SPEED) - canopy_mat.set_shader_parameter("wind_phase", wind_phase) - canopy_mesh.material = canopy_mat + var canopy_color: Color = COLORS[canopy_variant] + var crown_height := 3.7 + (canopy_radius - 1.2) * 0.45 + _add_canopy( + "Canopy", + Vector3(0.0, crown_height, 0.0), + Vector3(canopy_radius * 1.12, canopy_radius, canopy_radius), + canopy_color, + wind_phase + ) + _add_canopy( + "CanopyLeft", + Vector3(-0.82, crown_height - 0.15, 0.1), + Vector3(canopy_radius * 0.78, canopy_radius * 0.72, canopy_radius * 0.76), + canopy_color.lightened(0.05), + wind_phase + 0.12 + ) + _add_canopy( + "CanopyRight", + Vector3(0.78, crown_height - 0.05, -0.12), + Vector3(canopy_radius * 0.74, canopy_radius * 0.68, canopy_radius * 0.72), + canopy_color.darkened(0.04), + wind_phase - 0.1 + ) + _add_canopy( + "CanopyTop", + Vector3(0.08, crown_height + 0.72, 0.04), + Vector3(canopy_radius * 0.72, canopy_radius * 0.66, canopy_radius * 0.7), + canopy_color.lightened(0.08), + wind_phase + 0.05 + ) + +func _add_branch( + branch_position: Vector3, + branch_rotation_degrees: Vector3, + branch_height: float, + material: StandardMaterial3D +) -> void: + var mesh := CylinderMesh.new() + mesh.top_radius = 0.08 + mesh.bottom_radius = 0.14 + mesh.height = branch_height + mesh.radial_segments = 7 + mesh.material = material + var branch := MeshInstance3D.new() + branch.name = "Branch" + branch.mesh = mesh + branch.position = branch_position + branch.rotation_degrees = branch_rotation_degrees + add_child(branch) + + +func _add_canopy( + canopy_name: String, + canopy_position: Vector3, + canopy_scale: Vector3, + canopy_color: Color, + wind_phase: float +) -> void: + var mesh := SphereMesh.new() + mesh.radius = 1.0 + mesh.height = 2.0 + mesh.radial_segments = 12 + mesh.rings = 6 + var material := ShaderMaterial.new() + material.shader = WIND_SHADER + material.set_shader_parameter("albedo", canopy_color) + material.set_shader_parameter("roughness", 0.9) + material.set_shader_parameter("wind_strength", WIND_STRENGTH) + material.set_shader_parameter("wind_speed", WIND_SPEED) + material.set_shader_parameter("wind_phase", wind_phase) + mesh.material = material var canopy := MeshInstance3D.new() - canopy.name = "Canopy" - canopy.mesh = canopy_mesh - canopy.position = Vector3(0, 3.2 + (canopy_radius - 1.35) * 0.5, 0) + canopy.name = canopy_name + canopy.mesh = mesh + canopy.position = canopy_position + canopy.scale = canopy_scale add_child(canopy) diff --git a/world/jajce/TerrainRiverRibbon.gd b/world/jajce/TerrainRiverRibbon.gd new file mode 100644 index 0000000..9a63ad9 --- /dev/null +++ b/world/jajce/TerrainRiverRibbon.gd @@ -0,0 +1,82 @@ +extends Node3D + +@export var terrain_path := NodePath("../../TerrainRoot/Terrain3D") +@export var river_material: Material +@export_range(6.0, 20.0, 0.5) var river_width := 8.0 +@export_range(2, 12, 1) var width_segments := 6 +@export_range(8, 96, 1) var length_segments := 36 +@export var local_start_z := -25.0 +@export var local_end_z := 40.0 +@export_range(0.05, 0.5, 0.01) var water_offset := 0.18 + +@onready var river_ribbon: MeshInstance3D = $RiverRibbon + + +func _ready() -> void: + call_deferred("_build_terrain_ribbon") + + +func _build_terrain_ribbon() -> void: + var terrain := get_node_or_null(terrain_path) as Terrain3D + if terrain == null or terrain.data == null or river_material == null: + push_warning("TerrainRiverRibbon: terrain or material is unavailable") + return + + var vertices := PackedVector3Array() + var normals := PackedVector3Array() + var uvs := PackedVector2Array() + var indices := PackedInt32Array() + var row_size := width_segments + 1 + for z_index in length_segments + 1: + var progress := float(z_index) / float(length_segments) + var local_z := lerpf(local_start_z, local_end_z, progress) + var downstream_curve := smoothstep(0.58, 1.0, progress) * 12.0 + var center_offset := sin(progress * TAU) * 1.4 + downstream_curve + var downstream_taper := lerpf(1.0, 0.45, smoothstep(0.65, 1.0, progress)) + var half_width := river_width * lerpf(0.52, 0.45, progress) * downstream_taper + for x_index in width_segments + 1: + var across := float(x_index) / float(width_segments) + var local_x := center_offset + lerpf(-half_width, half_width, across) + var world_sample := to_global(Vector3(local_x, 0.0, local_z)) + var terrain_height := terrain.data.get_height(world_sample) + if is_nan(terrain_height): + terrain_height = global_position.y + vertices.append( + to_local(Vector3(world_sample.x, terrain_height + water_offset, world_sample.z)) + ) + normals.append(Vector3.UP) + uvs.append(Vector2(across, progress)) + + for z_index in length_segments: + for x_index in width_segments: + var top_left := z_index * row_size + x_index + var top_right := top_left + 1 + var bottom_left := top_left + row_size + var bottom_right := bottom_left + 1 + indices.append_array( + [top_left, bottom_left, top_right, top_right, bottom_left, bottom_right] + ) + + var arrays := [] + arrays.resize(Mesh.ARRAY_MAX) + arrays[Mesh.ARRAY_VERTEX] = vertices + arrays[Mesh.ARRAY_NORMAL] = normals + arrays[Mesh.ARRAY_TEX_UV] = uvs + arrays[Mesh.ARRAY_INDEX] = indices + var mesh := ArrayMesh.new() + mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays) + mesh.surface_set_material(0, river_material) + river_ribbon.mesh = mesh + _align_pool_to_terrain(terrain) + + +func _align_pool_to_terrain(terrain: Terrain3D) -> void: + var pool := $PlungePool as MeshInstance3D + var pool_sample := to_global(Vector3(pool.position.x, 0.0, pool.position.z)) + var pool_height := terrain.data.get_height(pool_sample) + if is_nan(pool_height): + return + var local_surface := to_local(Vector3(pool_sample.x, pool_height + water_offset, pool_sample.z)) + for node_name in [&"PlungePool", &"FoamRing", &"FoamAtFall"]: + var surface := get_node(NodePath(node_name)) as MeshInstance3D + surface.position.y = local_surface.y diff --git a/world/jajce/TerrainRiverRibbon.gd.uid b/world/jajce/TerrainRiverRibbon.gd.uid new file mode 100644 index 0000000..958f16f --- /dev/null +++ b/world/jajce/TerrainRiverRibbon.gd.uid @@ -0,0 +1 @@ +uid://gri4c7mt0rmo diff --git a/world/jajce/WaterBody.tscn b/world/jajce/WaterBody.tscn index 0c097d0..362b4db 100644 --- a/world/jajce/WaterBody.tscn +++ b/world/jajce/WaterBody.tscn @@ -1,16 +1,61 @@ -[gd_scene load_steps=4 format=3] +[gd_scene load_steps=8 format=3] [ext_resource type="Shader" path="res://world/jajce/materials/water.gdshader" id="1_water_shader"] +[ext_resource type="Script" path="res://world/jajce/TerrainRiverRibbon.gd" id="2_river_script"] [sub_resource type="ShaderMaterial" id="ShaderMaterial_water"] shader = ExtResource("1_water_shader") -[sub_resource type="BoxMesh" id="Mesh_water_body"] +[sub_resource type="CylinderMesh" id="Mesh_plunge_pool"] material = SubResource("ShaderMaterial_water") -size = Vector3(8, 0.08, 58) +top_radius = 7.6 +bottom_radius = 7.6 +height = 0.08 +radial_segments = 32 +rings = 2 + +[sub_resource type="StandardMaterial3D" id="Material_foam"] +transparency = 1 +shading_mode = 0 +albedo_color = Color(0.8, 0.93, 0.86, 0.46) +emission_enabled = true +emission = Color(0.34, 0.56, 0.48, 1) +emission_energy_multiplier = 0.18 +roughness = 0.74 + +[sub_resource type="TorusMesh" id="Mesh_foam_ring"] +material = SubResource("Material_foam") +inner_radius = 4.8 +outer_radius = 5.65 +rings = 28 +ring_segments = 10 + +[sub_resource type="PlaneMesh" id="Mesh_foam_patch"] +material = SubResource("Material_foam") +size = Vector2(10.5, 2.2) +subdivide_width = 8 +subdivide_depth = 2 [node name="WaterBody" type="Node3D"] -position = Vector3(25, 0.08, 0) +position = Vector3(25, 0.1, 1) +script = ExtResource("2_river_script") +river_material = SubResource("ShaderMaterial_water") -[node name="Mesh" type="MeshInstance3D" parent="."] -mesh = SubResource("Mesh_water_body") +[node name="RiverRibbon" type="MeshInstance3D" parent="."] +cast_shadow = 0 + +[node name="PlungePool" type="MeshInstance3D" parent="."] +position = Vector3(0, -0.02, -25) +scale = Vector3(1.25, 1, 0.82) +mesh = SubResource("Mesh_plunge_pool") + +[node name="FoamRing" type="MeshInstance3D" parent="."] +position = Vector3(0, 0.09, -25) +scale = Vector3(1.25, 0.12, 0.82) +cast_shadow = 0 +mesh = SubResource("Mesh_foam_ring") + +[node name="FoamAtFall" type="MeshInstance3D" parent="."] +position = Vector3(0, 0.12, -25.8) +cast_shadow = 0 +mesh = SubResource("Mesh_foam_patch") diff --git a/world/jajce/WaterfallBody.tscn b/world/jajce/WaterfallBody.tscn index 4708fef..840fc9d 100644 --- a/world/jajce/WaterfallBody.tscn +++ b/world/jajce/WaterfallBody.tscn @@ -1,16 +1,81 @@ -[gd_scene load_steps=4 format=3] +[gd_scene load_steps=10 format=3] [ext_resource type="Shader" path="res://world/jajce/materials/waterfall.gdshader" id="1_waterfall_shader"] [sub_resource type="ShaderMaterial" id="ShaderMaterial_waterfall"] shader = ExtResource("1_waterfall_shader") -[sub_resource type="BoxMesh" id="Mesh_waterfall_body"] +[sub_resource type="QuadMesh" id="Mesh_waterfall_body"] material = SubResource("ShaderMaterial_waterfall") -size = Vector3(8, 12, 0.5) +size = Vector2(10.8, 14) +subdivide_width = 18 +subdivide_depth = 28 + +[sub_resource type="StandardMaterial3D" id="Material_rock_dark"] +albedo_color = Color(0.32, 0.36, 0.3, 1) +roughness = 1.0 + +[sub_resource type="StandardMaterial3D" id="Material_rock_moss"] +albedo_color = Color(0.3, 0.4, 0.23, 1) +roughness = 1.0 + +[sub_resource type="CylinderMesh" id="Mesh_cliff_side"] +material = SubResource("Material_rock_dark") +top_radius = 2.05 +bottom_radius = 3.15 +height = 14.5 +radial_segments = 7 + +[sub_resource type="BoxMesh" id="Mesh_rock_lobe"] +material = SubResource("Material_rock_dark") +size = Vector3(4.8, 5.5, 3.4) + +[sub_resource type="BoxMesh" id="Mesh_lip"] +material = SubResource("Material_rock_moss") +size = Vector3(11.6, 0.7, 3.2) [node name="WaterfallBody" type="Node3D"] -position = Vector3(25, 6, -24) +position = Vector3(25, 8.9, -24) -[node name="Mesh" type="MeshInstance3D" parent="."] +[node name="RockFrame" type="Node3D" parent="."] + +[node name="WestCliff" type="MeshInstance3D" parent="RockFrame"] +position = Vector3(-7.2, -0.45, -1) +rotation_degrees = Vector3(0, 8, -3) +mesh = SubResource("Mesh_cliff_side") + +[node name="EastCliff" type="MeshInstance3D" parent="RockFrame"] +position = Vector3(7.2, -0.25, -1.2) +rotation_degrees = Vector3(0, -7, 4) +mesh = SubResource("Mesh_cliff_side") + +[node name="WestCrownRock" type="MeshInstance3D" parent="RockFrame"] +position = Vector3(-5.0, 7.45, -1.25) +rotation_degrees = Vector3(8, -14, -11) +scale = Vector3(0.85, 0.62, 0.9) +mesh = SubResource("Mesh_rock_lobe") + +[node name="EastCrownRock" type="MeshInstance3D" parent="RockFrame"] +position = Vector3(5.1, 7.55, -1.4) +rotation_degrees = Vector3(-6, 12, 9) +scale = Vector3(0.82, 0.6, 0.86) +mesh = SubResource("Mesh_rock_lobe") + +[node name="WestLowerRock" type="MeshInstance3D" parent="RockFrame"] +position = Vector3(-5.9, -4.8, 0.1) +rotation_degrees = Vector3(7, 14, 12) +mesh = SubResource("Mesh_rock_lobe") + +[node name="EastLowerRock" type="MeshInstance3D" parent="RockFrame"] +position = Vector3(6.0, -4.4, 0) +rotation_degrees = Vector3(-4, -12, -9) +mesh = SubResource("Mesh_rock_lobe") + +[node name="RiverLip" type="MeshInstance3D" parent="RockFrame"] +position = Vector3(0, 7.05, 0.2) +mesh = SubResource("Mesh_lip") + +[node name="FallingWater" type="MeshInstance3D" parent="."] +position = Vector3(0, 0, 1.05) +cast_shadow = 0 mesh = SubResource("Mesh_waterfall_body") diff --git a/world/jajce/WaterfallMist.tscn b/world/jajce/WaterfallMist.tscn index 7d290c9..6c0abc3 100644 --- a/world/jajce/WaterfallMist.tscn +++ b/world/jajce/WaterfallMist.tscn @@ -10,7 +10,7 @@ scale_min = 0.08 scale_max = 2.0 color = Color(0.82, 0.94, 0.96, 0.35) emission_shape = 1 -emission_box_extents = Vector3(4.5, 1.5, 1.0) +emission_box_extents = Vector3(5.8, 1.5, 1.35) [sub_resource type="StandardMaterial3D" id="Material_mist"] albedo_color = Color(0.82, 0.94, 0.96, 0.28) @@ -24,9 +24,9 @@ material = SubResource("Material_mist") size = Vector2(1.8, 1.8) [node name="WaterfallMist" type="GPUParticles3D"] -position = Vector3(25, 0.8, -24) +position = Vector3(25, 2.05, -24) emitting = true -amount = 72 +amount = 96 lifetime = 3.0 one_shot = false preprocess = 1.5 diff --git a/world/jajce/materials/cozy_grass.gdshader b/world/jajce/materials/cozy_grass.gdshader new file mode 100644 index 0000000..5a5ee00 --- /dev/null +++ b/world/jajce/materials/cozy_grass.gdshader @@ -0,0 +1,70 @@ +// Adapted for The Steward from the bundled Terrain3D particle-grass example. +shader_type spatial; + +render_mode skip_vertex_transform, cull_disabled, blend_mix; + +const int MAX_INTERACTORS = 8; + +uniform vec2 wind_direction = vec2(1.0, 0.7); +uniform vec3 base_color : source_color = vec3(0.16, 0.31, 0.08); +uniform vec3 tip_color : source_color = vec3(0.54, 0.68, 0.24); +uniform float wind_amount : hint_range(0.0, 1.0) = 0.34; +uniform int interactor_count : hint_range(0, 8) = 0; +uniform vec3 interactor_positions[MAX_INTERACTORS]; +uniform float interaction_radius : hint_range(0.25, 4.0) = 1.8; +uniform float interaction_strength : hint_range(0.0, 2.0) = 0.75; + +varying vec4 grass_data; +varying float pressed_weight; + + +void vertex() { + grass_data[2] = VERTEX.y + 0.55; + grass_data[2] *= grass_data[2]; + VERTEX.xz *= 1.0 - grass_data[2]; + COLOR = mix(COLOR, vec4(1.0), smoothstep(0.9, 1.0, grass_data[2])); + COLOR *= INSTANCE_CUSTOM[3] < 0.35 ? 1.0 : mix(1.0, 0.75, smoothstep(0.35, 0.0, grass_data[2])); + grass_data.rg = INSTANCE_CUSTOM.rg; + + vec3 world_vertex = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz; + float scale = pow(INSTANCE_CUSTOM[3] * INSTANCE_CUSTOM[3], 0.707); + float force = INSTANCE_CUSTOM[2] * grass_data[2] * scale * wind_amount; + force -= fract(force * 256.0) * force * 0.05; + force = pow(max(force, 0.0), 0.707); + float lateral_wobble = sin(TIME * 2.0 * (1.0 + grass_data.r + grass_data.g)) * 0.2 * (1.0 - INSTANCE_CUSTOM[2]); + vec2 direction = normalize(wind_direction); + world_vertex.xz -= (vec2(-direction.y, direction.x) * lateral_wobble + direction) * force; + world_vertex.y -= INSTANCE_CUSTOM[2] * force * grass_data[2]; + grass_data[3] = force; + + pressed_weight = 0.0; + for (int index = 0; index < MAX_INTERACTORS; index++) { + if (index >= interactor_count) { + break; + } + vec2 offset = world_vertex.xz - interactor_positions[index].xz; + float distance_to_actor = length(offset); + float influence = 1.0 - smoothstep(0.25, interaction_radius, distance_to_actor); + vec2 away = offset / max(distance_to_actor, 0.08); + world_vertex.xz += away * influence * interaction_strength * grass_data[2]; + world_vertex.y -= influence * 0.28 * grass_data[2]; + pressed_weight = max(pressed_weight, influence); + } + + VERTEX = (VIEW_MATRIX * vec4(world_vertex, 1.0)).xyz; + NORMAL = MODELVIEW_NORMAL_MATRIX * NORMAL; + BINORMAL = MODELVIEW_NORMAL_MATRIX * BINORMAL; + TANGENT = MODELVIEW_NORMAL_MATRIX * TANGENT; +} + + +void fragment() { + vec3 color = mix(base_color, tip_color, grass_data[2] * 0.62); + color.rg *= grass_data.rg * 0.18 + 0.91; + color *= pow(COLOR.rgb, vec3(2.2)); + ALBEDO = mix(color, base_color * 0.72, pressed_weight * 0.42); + float specular_roughness = clamp(max(grass_data[2], grass_data[3]), 0.0, 1.0); + ROUGHNESS = 1.0 - specular_roughness * 0.22; + SPECULAR = clamp(specular_roughness * 0.2, 0.0, 0.12); + BACKLIGHT = tip_color * 0.24; +} diff --git a/world/jajce/materials/cozy_grass.gdshader.uid b/world/jajce/materials/cozy_grass.gdshader.uid new file mode 100644 index 0000000..310bb7a --- /dev/null +++ b/world/jajce/materials/cozy_grass.gdshader.uid @@ -0,0 +1 @@ +uid://3ayekivfakfr diff --git a/world/jajce/materials/cozy_grass_material.tres b/world/jajce/materials/cozy_grass_material.tres new file mode 100644 index 0000000..1bb6e0a --- /dev/null +++ b/world/jajce/materials/cozy_grass_material.tres @@ -0,0 +1,14 @@ +[gd_resource type="ShaderMaterial" load_steps=2 format=3] + +[ext_resource type="Shader" path="res://world/jajce/materials/cozy_grass.gdshader" id="1_grass"] + +[resource] +render_priority = 0 +shader = ExtResource("1_grass") +shader_parameter/wind_direction = Vector2(1, 0.7) +shader_parameter/base_color = Vector3(0.16, 0.31, 0.08) +shader_parameter/tip_color = Vector3(0.54, 0.68, 0.24) +shader_parameter/wind_amount = 0.34 +shader_parameter/interactor_count = 0 +shader_parameter/interaction_radius = 1.8 +shader_parameter/interaction_strength = 0.75 diff --git a/world/jajce/materials/cozy_grass_process_material.tres b/world/jajce/materials/cozy_grass_process_material.tres new file mode 100644 index 0000000..3c643dc --- /dev/null +++ b/world/jajce/materials/cozy_grass_process_material.tres @@ -0,0 +1,47 @@ +[gd_resource type="ShaderMaterial" load_steps=4 format=3] + +[ext_resource type="Shader" path="res://addons/terrain_3d/extras/particle_example/particles.gdshader" id="1_process_shader"] + +[sub_resource type="FastNoiseLite" id="FastNoise_grass"] +noise_type = 2 +frequency = 0.018 +cellular_return_type = 4 + +[sub_resource type="NoiseTexture2D" id="NoiseTexture_grass"] +seamless = true +noise = SubResource("FastNoise_grass") + +[resource] +shader = ExtResource("1_process_shader") +shader_parameter/main_noise = SubResource("NoiseTexture_grass") +shader_parameter/main_noise_scale = 0.012 +shader_parameter/position_offset = Vector3(0, 0.22, 0) +shader_parameter/align_to_normal = true +shader_parameter/normal_strength = 0.22 +shader_parameter/random_rotation = true +shader_parameter/random_spacing = 0.72 +shader_parameter/min_scale = Vector3(0.09, 0.22, 0.09) +shader_parameter/max_scale = Vector3(0.13, 0.48, 0.13) +shader_parameter/noise_scale = 0.0041 +shader_parameter/wind_speed = 0.022 +shader_parameter/wind_strength = 0.6 +shader_parameter/wind_dithering = 4.0 +shader_parameter/wind_direction = Vector2(1, 0.7) +shader_parameter/clod_scale_boost = 0.18 +shader_parameter/clod_min_threshold = 0.32 +shader_parameter/clod_max_threshold = 0.78 +shader_parameter/patch_min_threshold = 0.08 +shader_parameter/patch_max_threshold = 0.42 +shader_parameter/condition_dither_range = 0.18 +shader_parameter/surface_slope_min = 0.88 +shader_parameter/distance_fade_ammount = 0.7 +shader_parameter/max_dist = 1.0 +shader_parameter/camera_position = Vector3(0, 0, 0) +shader_parameter/instance_rows = 1 +shader_parameter/instance_spacing = 0.703125 +shader_parameter/_background_mode = 0 +shader_parameter/_vertex_spacing = 1.0 +shader_parameter/_vertex_density = 1.0 +shader_parameter/_region_size = 1024.0 +shader_parameter/_region_texel_size = 0.000976563 +shader_parameter/_region_map_size = 32 diff --git a/world/jajce/materials/water.gdshader b/world/jajce/materials/water.gdshader index 3d91248..85c9c2e 100644 --- a/world/jajce/materials/water.gdshader +++ b/world/jajce/materials/water.gdshader @@ -1,54 +1,42 @@ shader_type spatial; -render_mode blend_mix, depth_draw_opaque, cull_back, diffuse_burley, specular_schlick_ggx; +render_mode blend_mix, depth_prepass_alpha, cull_disabled, diffuse_burley, specular_schlick_ggx; -uniform float wave_strength : hint_range(0.0, 0.5) = 0.08; -uniform float wave_speed : hint_range(0.0, 5.0) = 0.8; -uniform float flow_speed : hint_range(0.0, 3.0) = 0.6; -uniform vec3 deep_color : source_color = vec3(0.06, 0.24, 0.38); -uniform vec3 shallow_color : source_color = vec3(0.18, 0.44, 0.58); -uniform vec3 foam_color : source_color = vec3(0.82, 0.88, 0.86); -uniform float rim_power : hint_range(0.5, 5.0) = 2.5; -uniform float sparkle_strength : hint_range(0.0, 1.0) = 0.12; +uniform float wave_strength : hint_range(0.0, 0.3) = 0.055; +uniform float wave_speed : hint_range(0.0, 3.0) = 0.72; +uniform float flow_speed : hint_range(0.0, 2.0) = 0.46; +uniform vec3 deep_color : source_color = vec3(0.03, 0.39, 0.45); +uniform vec3 shallow_color : source_color = vec3(0.07, 0.67, 0.64); +uniform vec3 sunlit_color : source_color = vec3(0.55, 0.95, 0.78); +uniform vec3 foam_color : source_color = vec3(0.82, 0.94, 0.84); -uniform vec3 sun_direction = vec3(0.4, 0.8, 0.4); void vertex() { - vec3 pos = VERTEX; - float wave_a = sin(pos.x * 0.35 + pos.z * 0.25 + TIME * wave_speed) * wave_strength; - float wave_b = cos(pos.x * 0.45 - pos.z * 0.55 + TIME * wave_speed * 0.7) * wave_strength * 0.6; - float wave_c = sin(pos.z * 0.7 + TIME * wave_speed * 1.3) * wave_strength * 0.35; - pos.y += wave_a + wave_b + wave_c; - VERTEX = pos; + float broad_wave = sin(VERTEX.x * 0.42 + VERTEX.z * 0.18 + TIME * wave_speed); + float crossing_wave = cos(VERTEX.x * 0.7 - VERTEX.z * 0.31 + TIME * wave_speed * 0.73); + VERTEX.y += (broad_wave + crossing_wave * 0.45) * wave_strength; } + void fragment() { - vec3 view_dir = VIEW; - vec3 normal = NORMAL; - vec3 view_norm = normalize(view_dir); + vec2 flowing_uv = UV * vec2(7.0, 10.0); + flowing_uv.y -= TIME * flow_speed; + float ribbon_a = sin(flowing_uv.y + sin(flowing_uv.x * 0.72) * 1.25) * 0.5 + 0.5; + float ribbon_b = sin(flowing_uv.y * 1.7 - flowing_uv.x * 0.55 + TIME * 0.23) * 0.5 + 0.5; + float quiet_ripple = smoothstep(0.72, 0.98, ribbon_a * 0.62 + ribbon_b * 0.38); + float fresnel = pow(1.0 - clamp(dot(normalize(NORMAL), normalize(VIEW)), 0.0, 1.0), 2.4); + float bank_distance = min(UV.x, 1.0 - UV.x); + float bank_foam = 1.0 - smoothstep(0.0, 0.07, bank_distance); + bank_foam *= 0.72 + sin(UV.y * 52.0 + TIME * 0.35) * 0.12; - float rim = 1.0 - abs(dot(normal, view_norm)); - rim = pow(rim, rim_power); - rim = smoothstep(0.15, 0.7, rim); - - vec2 uv = UV; - uv.x += TIME * flow_speed * 0.4; - uv.y += TIME * flow_speed * 0.3; - float flow_a = sin(uv.x * 8.0 + uv.y * 5.0) * 0.5 + 0.5; - float flow_b = cos(uv.y * 6.0 - uv.x * 4.0 + TIME * 0.25) * 0.5 + 0.5; - float flow = mix(flow_a, flow_b, 0.5); - - vec3 water_color = mix(deep_color, shallow_color, flow * 0.6 + 0.2); - water_color = mix(water_color, foam_color, rim * 0.65); - - vec3 half_vec = normalize(view_norm + normalize(sun_direction)); - float specular = pow(max(dot(normal, half_vec), 0.0), 120.0); - specular *= smoothstep(0.4, 0.85, rim); - float sparkle = specular * sparkle_strength; + vec3 water_color = mix(deep_color, shallow_color, ribbon_a * 0.2 + 0.48); + water_color = mix(water_color, sunlit_color, fresnel * 0.42); + water_color = mix(water_color, foam_color, quiet_ripple * 0.05); + water_color = mix(water_color, foam_color, bank_foam * 0.3); ALBEDO = water_color; - METALLIC = 0.08; - ROUGHNESS = 0.22; - EMISSION = water_color * 0.04 + foam_color * sparkle; - ALPHA = 0.76; - ALPHA_SCISSOR_THRESHOLD = 0.0; + ROUGHNESS = mix(0.34, 0.5, ribbon_b); + SPECULAR = 0.38; + EMISSION = water_color * 0.12 + sunlit_color * (quiet_ripple * 0.05 + fresnel * 0.03); + EMISSION += foam_color * bank_foam * 0.025; + ALPHA = mix(0.94, 0.99, fresnel); } diff --git a/world/jajce/materials/waterfall.gdshader b/world/jajce/materials/waterfall.gdshader index 143ce97..cf8e7e1 100644 --- a/world/jajce/materials/waterfall.gdshader +++ b/world/jajce/materials/waterfall.gdshader @@ -1,50 +1,38 @@ shader_type spatial; -render_mode blend_mix, depth_draw_opaque, cull_back, diffuse_burley, specular_schlick_ggx; +render_mode blend_mix, depth_prepass_alpha, cull_disabled, diffuse_burley, specular_schlick_ggx; + +uniform float scroll_speed : hint_range(0.0, 4.0) = 1.35; +uniform vec3 shadow_color : source_color = vec3(0.075, 0.33, 0.34); +uniform vec3 water_color : source_color = vec3(0.31, 0.68, 0.64); +uniform vec3 light_color : source_color = vec3(0.78, 0.94, 0.83); +uniform vec3 foam_color : source_color = vec3(0.91, 0.97, 0.9); -uniform float scroll_speed : hint_range(0.0, 5.0) = 1.8; -uniform vec3 deep_color : source_color = vec3(0.1, 0.45, 0.55); -uniform vec3 mid_color : source_color = vec3(0.45, 0.78, 0.82); -uniform vec3 foam_color : source_color = vec3(0.9, 0.95, 0.95); -uniform float foam_contrast : hint_range(0.5, 4.0) = 2.2; -uniform float sparkle_strength : hint_range(0.0, 0.8) = 0.25; void vertex() { + float edge_weight = 1.0 - abs(UV.x * 2.0 - 1.0); + float curtain_wobble = sin(UV.y * 13.0 - TIME * 2.2 + UV.x * 8.0) * 0.05; + VERTEX.z += curtain_wobble * edge_weight; } + void fragment() { - vec2 uv = UV; - uv.y += TIME * scroll_speed; + vec2 flow_uv = UV; + flow_uv.y += TIME * scroll_speed; + float long_thread = sin(flow_uv.x * 32.0 + sin(flow_uv.y * 3.1) * 2.4) * 0.5 + 0.5; + float soft_thread = sin(flow_uv.x * 13.0 - flow_uv.y * 7.0) * 0.5 + 0.5; + float falling_detail = sin(flow_uv.y * 27.0 + flow_uv.x * 7.0) * 0.5 + 0.5; + float thread = smoothstep(0.36, 0.9, long_thread * 0.6 + soft_thread * 0.4); + float broken_foam = smoothstep(0.72, 0.98, falling_detail * thread); + float base_foam = smoothstep(0.58, 1.0, UV.y); + float edge_fade = smoothstep(0.0, 0.08, UV.x) * smoothstep(1.0, 0.92, UV.x); - float band_a = fract(uv.y * 5.0 + sin(uv.x * 2.0) * 0.15); - band_a = smoothstep(0.2, 0.55, band_a) * smoothstep(0.8, 0.45, band_a); - - float band_b = fract(uv.y * 3.5 + cos(uv.x * 2.5 + TIME * 0.3) * 0.12); - band_b = smoothstep(0.1, 0.5, band_b) * smoothstep(0.85, 0.4, band_b); - - float foam_a = fract(uv.y * 4.0); - foam_a = pow(smoothstep(0.0, 0.12, foam_a) * smoothstep(0.3, 0.15, foam_a), foam_contrast); - - float foam_b = fract(uv.y * 6.0 + uv.x * 1.5); - foam_b = pow(smoothstep(0.5, 0.58, foam_b) * smoothstep(0.7, 0.6, foam_b), foam_contrast * 0.8); - - float foam = foam_a * 0.7 + foam_b * 0.3; - - vec3 base = mix(deep_color, mid_color, band_a * 0.5 + band_b * 0.3); - vec3 color = mix(base, foam_color, foam); - - float sparkle = pow(foam_a, 3.0) * sparkle_strength; - float highlight = band_a * band_b * 0.35; - float emission_value = sparkle + highlight * 0.3; + vec3 color = mix(shadow_color, water_color, 0.5 + thread * 0.35); + color = mix(color, light_color, broken_foam * 0.5); + color = mix(color, foam_color, base_foam * (0.38 + broken_foam * 0.42)); ALBEDO = color; - ALPHA = mix(0.25, 0.78, foam * 0.7 + 0.15); - EMISSION = color * emission_value; - METALLIC = 0.05; - ROUGHNESS = 0.15; - - vec3 normal = NORMAL; - vec3 view_dir = VIEW; - vec3 view_norm = normalize(view_dir); - float rim = 1.0 - abs(dot(normal, view_norm)); - ALPHA = mix(ALPHA, ALPHA * 0.5, rim * 0.6); + ROUGHNESS = mix(0.16, 0.32, broken_foam); + SPECULAR = 0.65; + EMISSION = light_color * (broken_foam * 0.07 + base_foam * 0.04); + ALPHA = edge_fade * mix(0.62, 0.9, thread * 0.56 + base_foam * 0.3); }