diff --git a/art/meadow/.gdignore b/art/meadow/.gdignore new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/art/meadow/.gdignore @@ -0,0 +1 @@ + diff --git a/art/meadow/cream_daisy.blend b/art/meadow/cream_daisy.blend new file mode 100644 index 0000000..3246356 Binary files /dev/null and b/art/meadow/cream_daisy.blend differ diff --git a/art/meadow/lavender_spire.blend b/art/meadow/lavender_spire.blend new file mode 100644 index 0000000..587c250 Binary files /dev/null and b/art/meadow/lavender_spire.blend differ diff --git a/art/meadow/pink_cosmos.blend b/art/meadow/pink_cosmos.blend new file mode 100644 index 0000000..e0d46f8 Binary files /dev/null and b/art/meadow/pink_cosmos.blend differ diff --git a/art/meadow/yellow_buttercup.blend b/art/meadow/yellow_buttercup.blend new file mode 100644 index 0000000..fcd20a8 Binary files /dev/null and b/art/meadow/yellow_buttercup.blend differ diff --git a/art/painterly/.gdignore b/art/painterly/.gdignore new file mode 100644 index 0000000..e69de29 diff --git a/art/painterly/painterly_conifer.blend b/art/painterly/painterly_conifer.blend new file mode 100644 index 0000000..66b80b1 Binary files /dev/null and b/art/painterly/painterly_conifer.blend differ diff --git a/art/painterly/painterly_tree.blend b/art/painterly/painterly_tree.blend new file mode 100644 index 0000000..60aa494 Binary files /dev/null and b/art/painterly/painterly_tree.blend differ diff --git a/assets/meadow/README.md b/assets/meadow/README.md new file mode 100644 index 0000000..6264d41 --- /dev/null +++ b/assets/meadow/README.md @@ -0,0 +1,21 @@ +# Jajce meadow flowers + +Original mesh assets built for The Steward. Four small clumps give authored +meadow drifts a cream, rose, butter-yellow and lavender palette. Each contains +three stems, folded leaves and opaque petal geometry with painted vertex colors. +There are no external textures, dependencies, collision shapes or gameplay IDs. + +Editable sources live in `art/meadow/`, outside Godot's import tree. Rebuild with +Blender 5.1 or later: + +```sh +Blender --background --python tools/art/build_meadow_flowers.py +``` + +`mesh_budget.json` records exported triangle/surface/file-size counts. Runtime +`PainterlyMeadow` uses one MultiMesh per species and its own soft-lit wind shader; +the GLB material preserves the original palette when opened in other tools. + +Godot 4.7 Compatibility requires explicit white MultiMesh instance colors when +the custom instance buffer is enabled. `PainterlyMeadow` supplies that neutral +color so both Compatibility and Forward+ preserve the painted GLB palette. diff --git a/assets/meadow/cream_daisy.glb b/assets/meadow/cream_daisy.glb new file mode 100644 index 0000000..4e18335 Binary files /dev/null and b/assets/meadow/cream_daisy.glb differ diff --git a/assets/meadow/cream_daisy.glb.import b/assets/meadow/cream_daisy.glb.import new file mode 100644 index 0000000..1208c9c --- /dev/null +++ b/assets/meadow/cream_daisy.glb.import @@ -0,0 +1,45 @@ +[remap] + +importer="scene" +importer_version=1 +type="PackedScene" +uid="uid://cnoywbu65c0hg" +path="res://.godot/imported/cream_daisy.glb-7a51f417838affb7d915e8a81cdabcc9.scn" + +[deps] + +source_file="res://assets/meadow/cream_daisy.glb" +dest_files=["res://.godot/imported/cream_daisy.glb-7a51f417838affb7d915e8a81cdabcc9.scn"] + +[params] + +nodes/root_type="" +nodes/root_name="" +nodes/root_script=null +mesh_library/use_node_names_as_mesh_names=false +array_mesh/deduplicate_surfaces=true +nodes/apply_root_scale=true +nodes/root_scale=1.0 +nodes/import_as_skeleton_bones=false +nodes/use_name_suffixes=true +nodes/use_node_type_suffixes=true +meshes/ensure_tangents=true +meshes/generate_lods=true +meshes/create_shadow_meshes=true +meshes/light_baking=1 +meshes/lightmap_texel_size=0.2 +meshes/force_disable_compression=false +skins/use_named_skins=true +animation/import=true +animation/fps=30 +animation/trimming=false +animation/remove_immutable_tracks=true +animation/import_rest_as_RESET=false +import_script/path="" +materials/extract=0 +materials/extract_format=0 +materials/extract_path="" +_subresources={} +gltf/naming_version=2 +gltf/embedded_image_handling=1 +gltf/texture_map_mode=1 diff --git a/assets/meadow/lavender_spire.glb b/assets/meadow/lavender_spire.glb new file mode 100644 index 0000000..7dbed54 Binary files /dev/null and b/assets/meadow/lavender_spire.glb differ diff --git a/assets/meadow/lavender_spire.glb.import b/assets/meadow/lavender_spire.glb.import new file mode 100644 index 0000000..7e3b73b --- /dev/null +++ b/assets/meadow/lavender_spire.glb.import @@ -0,0 +1,45 @@ +[remap] + +importer="scene" +importer_version=1 +type="PackedScene" +uid="uid://dur42glc0ybm3" +path="res://.godot/imported/lavender_spire.glb-ca4666e5e14c84270d4621d49918d5e7.scn" + +[deps] + +source_file="res://assets/meadow/lavender_spire.glb" +dest_files=["res://.godot/imported/lavender_spire.glb-ca4666e5e14c84270d4621d49918d5e7.scn"] + +[params] + +nodes/root_type="" +nodes/root_name="" +nodes/root_script=null +mesh_library/use_node_names_as_mesh_names=false +array_mesh/deduplicate_surfaces=true +nodes/apply_root_scale=true +nodes/root_scale=1.0 +nodes/import_as_skeleton_bones=false +nodes/use_name_suffixes=true +nodes/use_node_type_suffixes=true +meshes/ensure_tangents=true +meshes/generate_lods=true +meshes/create_shadow_meshes=true +meshes/light_baking=1 +meshes/lightmap_texel_size=0.2 +meshes/force_disable_compression=false +skins/use_named_skins=true +animation/import=true +animation/fps=30 +animation/trimming=false +animation/remove_immutable_tracks=true +animation/import_rest_as_RESET=false +import_script/path="" +materials/extract=0 +materials/extract_format=0 +materials/extract_path="" +_subresources={} +gltf/naming_version=2 +gltf/embedded_image_handling=1 +gltf/texture_map_mode=1 diff --git a/assets/meadow/mesh_budget.json b/assets/meadow/mesh_budget.json new file mode 100644 index 0000000..fc7d1b7 --- /dev/null +++ b/assets/meadow/mesh_budget.json @@ -0,0 +1,26 @@ +{ + "cream_daisy": { + "triangles": 180, + "surfaces": 1, + "flower_stems": 3, + "bytes": 19896 + }, + "pink_cosmos": { + "triangles": 171, + "surfaces": 1, + "flower_stems": 3, + "bytes": 18888 + }, + "yellow_buttercup": { + "triangles": 153, + "surfaces": 1, + "flower_stems": 3, + "bytes": 16840 + }, + "lavender_spire": { + "triangles": 360, + "surfaces": 1, + "flower_stems": 3, + "bytes": 40444 + } +} diff --git a/assets/meadow/pink_cosmos.glb b/assets/meadow/pink_cosmos.glb new file mode 100644 index 0000000..9ce5493 Binary files /dev/null and b/assets/meadow/pink_cosmos.glb differ diff --git a/assets/meadow/pink_cosmos.glb.import b/assets/meadow/pink_cosmos.glb.import new file mode 100644 index 0000000..272ef55 --- /dev/null +++ b/assets/meadow/pink_cosmos.glb.import @@ -0,0 +1,45 @@ +[remap] + +importer="scene" +importer_version=1 +type="PackedScene" +uid="uid://dqksrghyqlfec" +path="res://.godot/imported/pink_cosmos.glb-f2a2c19fa8aeebec56761be5c75e2426.scn" + +[deps] + +source_file="res://assets/meadow/pink_cosmos.glb" +dest_files=["res://.godot/imported/pink_cosmos.glb-f2a2c19fa8aeebec56761be5c75e2426.scn"] + +[params] + +nodes/root_type="" +nodes/root_name="" +nodes/root_script=null +mesh_library/use_node_names_as_mesh_names=false +array_mesh/deduplicate_surfaces=true +nodes/apply_root_scale=true +nodes/root_scale=1.0 +nodes/import_as_skeleton_bones=false +nodes/use_name_suffixes=true +nodes/use_node_type_suffixes=true +meshes/ensure_tangents=true +meshes/generate_lods=true +meshes/create_shadow_meshes=true +meshes/light_baking=1 +meshes/lightmap_texel_size=0.2 +meshes/force_disable_compression=false +skins/use_named_skins=true +animation/import=true +animation/fps=30 +animation/trimming=false +animation/remove_immutable_tracks=true +animation/import_rest_as_RESET=false +import_script/path="" +materials/extract=0 +materials/extract_format=0 +materials/extract_path="" +_subresources={} +gltf/naming_version=2 +gltf/embedded_image_handling=1 +gltf/texture_map_mode=1 diff --git a/assets/meadow/yellow_buttercup.glb b/assets/meadow/yellow_buttercup.glb new file mode 100644 index 0000000..5643871 Binary files /dev/null and b/assets/meadow/yellow_buttercup.glb differ diff --git a/assets/meadow/yellow_buttercup.glb.import b/assets/meadow/yellow_buttercup.glb.import new file mode 100644 index 0000000..eecc870 --- /dev/null +++ b/assets/meadow/yellow_buttercup.glb.import @@ -0,0 +1,45 @@ +[remap] + +importer="scene" +importer_version=1 +type="PackedScene" +uid="uid://urkk7ybwrnv7" +path="res://.godot/imported/yellow_buttercup.glb-88c3511b4d2c884beab5a1c921cdc3cb.scn" + +[deps] + +source_file="res://assets/meadow/yellow_buttercup.glb" +dest_files=["res://.godot/imported/yellow_buttercup.glb-88c3511b4d2c884beab5a1c921cdc3cb.scn"] + +[params] + +nodes/root_type="" +nodes/root_name="" +nodes/root_script=null +mesh_library/use_node_names_as_mesh_names=false +array_mesh/deduplicate_surfaces=true +nodes/apply_root_scale=true +nodes/root_scale=1.0 +nodes/import_as_skeleton_bones=false +nodes/use_name_suffixes=true +nodes/use_node_type_suffixes=true +meshes/ensure_tangents=true +meshes/generate_lods=true +meshes/create_shadow_meshes=true +meshes/light_baking=1 +meshes/lightmap_texel_size=0.2 +meshes/force_disable_compression=false +skins/use_named_skins=true +animation/import=true +animation/fps=30 +animation/trimming=false +animation/remove_immutable_tracks=true +animation/import_rest_as_RESET=false +import_script/path="" +materials/extract=0 +materials/extract_format=0 +materials/extract_path="" +_subresources={} +gltf/naming_version=2 +gltf/embedded_image_handling=1 +gltf/texture_map_mode=1 diff --git a/assets/painterly/README.md b/assets/painterly/README.md new file mode 100644 index 0000000..1fb42f4 --- /dev/null +++ b/assets/painterly/README.md @@ -0,0 +1,59 @@ +# Painterly valley assets + +Original broadleaf and evergreen meshes created in Blender for The Steward. +Scalloped crowns, painted vertex variation, crooked forks and root flares replace +the primitive tree silhouettes. Editable sources are in `art/painterly/`; +`.gdignore` keeps Blender outside Godot's runtime import dependencies. The two +portable GLBs use opaque geometry without textures or alpha cards. + +## Rebuild and mesh budget + +From the project root, using Blender 5.1 and Godot 4.7: + +```sh +"$BLENDER_BIN" --background --python tools/art/build_painterly_trees.py +"$GODOT_BIN" --headless --editor --path "$PWD" --import +``` + +Use the isolated Godot profile configured by `tools/quality.sh` for headless +validation. Runtime consumes the GLBs directly; no mesh-extraction step is +needed. Preserve their `.import` files when updating exports. + +| Geometry | Triangles | Surfaces | +| --- | ---: | ---: | +| Shared broadleaf crown | 1,236 | 1 | +| Joined broadleaf trunk, forks and roots | 406 | 1 | +| Complete broadleaf: four crowns plus trunk | 5,350 | 5 | +| Complete conifer: boughs plus trunk | 1,580 | 2 | + +`tree_mesh_budget.json` records these export counts. The broadleaf GLB is +45,676 bytes; the conifer GLB is 40,836 bytes and stands 3.10 m before placement +scaling. A separate temporary rebuild with Blender 5.1.2 produced byte-identical +copies of both GLBs and the budget JSON. This establishes generator +reproducibility with that Blender version, not identical `.blend` serialization +across versions. + +`StylizedTree` shares imported meshes and isolates crown tint/wind materials. +Its four direct `Canopy*` children retain the existing thinning and stump +presentation contract. `PainterlyConifer` shares imported bough/trunk meshes. +Both use the same world-direction breeze and explicit painted bark colors; +neither adds simulation state, collision or per-frame GDScript work. + +## Painted sky provenance + +`painted_sky.png` is an original 1774 × 887 image generated with the built-in +ImageGen tool, rather than a CLI generator. The prompt asks for a painted +cumulus panorama with warm ivory cloud tops, cool turquoise cloud shadows and +a clear blue summer atmosphere. Film references informed the color and mood; +no external film image was copied into the texture. The exact generation +prompt is recorded in [sky_prompt.md](sky_prompt.md). + +`storybook_sky.gdshader` maps the panorama's horizon at 69% of its height onto +the upper hemisphere, repeats the painted band twice around the azimuth to +retain cloud proportions, blends its seam/edge into haze, and follows the existing +day/night palette. The image supplies the sky backdrop only; it does not paint +terrain or create volumetric clouds. + +Final renderer QA and the complete project quality gate belong to the +integrated environment review. See the [presentation guide](../../docs/FEATURE_PRESENTATION_PERFORMANCE.md) +for current verification evidence and remaining hardware limits. diff --git a/assets/painterly/painted_sky.png b/assets/painterly/painted_sky.png new file mode 100644 index 0000000..da61308 Binary files /dev/null and b/assets/painterly/painted_sky.png differ diff --git a/assets/painterly/painted_sky.png.import b/assets/painterly/painted_sky.png.import new file mode 100644 index 0000000..f9c7e5c --- /dev/null +++ b/assets/painterly/painted_sky.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dmkubk6dhhyis" +path="res://.godot/imported/painted_sky.png-9888802278aff2f83effdab2649f1f05.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/painterly/painted_sky.png" +dest_files=["res://.godot/imported/painted_sky.png-9888802278aff2f83effdab2649f1f05.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/assets/painterly/painterly_conifer.glb b/assets/painterly/painterly_conifer.glb new file mode 100644 index 0000000..98f5389 Binary files /dev/null and b/assets/painterly/painterly_conifer.glb differ diff --git a/assets/painterly/painterly_conifer.glb.import b/assets/painterly/painterly_conifer.glb.import new file mode 100644 index 0000000..583fe54 --- /dev/null +++ b/assets/painterly/painterly_conifer.glb.import @@ -0,0 +1,45 @@ +[remap] + +importer="scene" +importer_version=1 +type="PackedScene" +uid="uid://clcvatvbyixma" +path="res://.godot/imported/painterly_conifer.glb-596d48094a6a072cfc85374d478c7bbe.scn" + +[deps] + +source_file="res://assets/painterly/painterly_conifer.glb" +dest_files=["res://.godot/imported/painterly_conifer.glb-596d48094a6a072cfc85374d478c7bbe.scn"] + +[params] + +nodes/root_type="" +nodes/root_name="" +nodes/root_script=null +mesh_library/use_node_names_as_mesh_names=false +array_mesh/deduplicate_surfaces=true +nodes/apply_root_scale=true +nodes/root_scale=1.0 +nodes/import_as_skeleton_bones=false +nodes/use_name_suffixes=true +nodes/use_node_type_suffixes=true +meshes/ensure_tangents=true +meshes/generate_lods=true +meshes/create_shadow_meshes=true +meshes/light_baking=1 +meshes/lightmap_texel_size=0.2 +meshes/force_disable_compression=false +skins/use_named_skins=true +animation/import=true +animation/fps=30 +animation/trimming=false +animation/remove_immutable_tracks=true +animation/import_rest_as_RESET=false +import_script/path="" +materials/extract=0 +materials/extract_format=0 +materials/extract_path="" +_subresources={} +gltf/naming_version=2 +gltf/embedded_image_handling=1 +gltf/texture_map_mode=1 diff --git a/assets/painterly/painterly_tree.glb b/assets/painterly/painterly_tree.glb new file mode 100644 index 0000000..20ac31e Binary files /dev/null and b/assets/painterly/painterly_tree.glb differ diff --git a/assets/painterly/painterly_tree.glb.import b/assets/painterly/painterly_tree.glb.import new file mode 100644 index 0000000..150d1a8 --- /dev/null +++ b/assets/painterly/painterly_tree.glb.import @@ -0,0 +1,45 @@ +[remap] + +importer="scene" +importer_version=1 +type="PackedScene" +uid="uid://blu5r5vupqvpd" +path="res://.godot/imported/painterly_tree.glb-12cb20acdabe08bb0fe06cba12958067.scn" + +[deps] + +source_file="res://assets/painterly/painterly_tree.glb" +dest_files=["res://.godot/imported/painterly_tree.glb-12cb20acdabe08bb0fe06cba12958067.scn"] + +[params] + +nodes/root_type="" +nodes/root_name="" +nodes/root_script=null +mesh_library/use_node_names_as_mesh_names=false +array_mesh/deduplicate_surfaces=true +nodes/apply_root_scale=true +nodes/root_scale=1.0 +nodes/import_as_skeleton_bones=false +nodes/use_name_suffixes=true +nodes/use_node_type_suffixes=true +meshes/ensure_tangents=true +meshes/generate_lods=true +meshes/create_shadow_meshes=true +meshes/light_baking=1 +meshes/lightmap_texel_size=0.2 +meshes/force_disable_compression=false +skins/use_named_skins=true +animation/import=true +animation/fps=30 +animation/trimming=false +animation/remove_immutable_tracks=true +animation/import_rest_as_RESET=false +import_script/path="" +materials/extract=0 +materials/extract_format=0 +materials/extract_path="" +_subresources={} +gltf/naming_version=2 +gltf/embedded_image_handling=1 +gltf/texture_map_mode=1 diff --git a/assets/painterly/sky_prompt.md b/assets/painterly/sky_prompt.md new file mode 100644 index 0000000..d11356a --- /dev/null +++ b/assets/painterly/sky_prompt.md @@ -0,0 +1,35 @@ +# Painted sky provenance + +Created for this project with the built-in imagegen tool on 2026-09-05. +Original output: 1774 × 887 PNG, copied without pixel editing to +`assets/painterly/painted_sky.png`. The sky shader samples the upper portion +and blends its horizon into the simulation-driven day/night palette. +No film frame or downloaded artwork is used as a runtime texture. + +## Generation prompt + +Use case: stylized-concept. Asset type: original 360-degree panoramic sky +texture for a Godot game, 2:1 equirectangular landscape, ideally 3072x1536. +Create ONLY SKY, absolutely no ground, hills, buildings, ocean, objects, text +or sun disk. Art direction: exquisitely hand-painted Japanese animation +background, Studio Ghibli inspired summer pastoral fantasy, gouache brush +texture, saturated clear turquoise-blue atmosphere, towering luminous cumulus +clouds with creamy warm sunlit edges, intricate scalloped billowing forms, +pale cyan and slate blue softly painted shadow volumes. Composition is a full +spherical sky environment texture: upper half of image shows the upward +hemisphere, zenith at top is mostly deep clean blue, large beautiful cumulus +towers occupy the upper middle band between y=15% and y=44%, and the horizon +at exactly image vertical halfway is uniform pale atmospheric aqua. Lower +half is entirely an unobtrusive smooth pale aqua color gradient, with NO +landscape. Cloud coverage about 40% of upper hemisphere, generous varied blue +gaps, asymmetrical naturally spaced cloud masses. Left and right edges must +wrap seamlessly in color and shape, keep thin strips at far left/right clear +sky to avoid seam. No photographic cloud noise, no 3D render or vector +outlines, no flat cartoon circles. Painterly refined layered brushwork like a +cinematic animated film background, not a flat gradient. Use only original +cloud shapes; no copied film imagery. + +The returned image does not exactly meet the requested resolution or horizon +position. The runtime shader uses its actual dimensions and remaps the painted +horizon at 69% image height. Blender mesh assets are deterministic rebuilds; +this generated image is a pinned art asset, not a deterministic generator output. diff --git a/assets/painterly/tree_mesh_budget.json b/assets/painterly/tree_mesh_budget.json new file mode 100644 index 0000000..aaf6d73 --- /dev/null +++ b/assets/painterly/tree_mesh_budget.json @@ -0,0 +1,24 @@ +{ + "Canopy": { + "triangles": 1236, + "surfaces": 1, + "vertices": 712 + }, + "Trunk": { + "triangles": 406, + "surfaces": 1, + "vertices": 221 + }, + "runtime_tree": { + "triangles": 5350, + "surfaces": 5, + "shared_meshes": 2 + }, + "glb_bytes": 45676, + "conifer": { + "triangles": 1580, + "surfaces": 2, + "height_m": 3.1, + "glb_bytes": 40836 + } +} diff --git a/docs/FEATURE_PRESENTATION_PERFORMANCE.md b/docs/FEATURE_PRESENTATION_PERFORMANCE.md index 4f7dffb..192f13c 100644 --- a/docs/FEATURE_PRESENTATION_PERFORMANCE.md +++ b/docs/FEATURE_PRESENTATION_PERFORMANCE.md @@ -38,11 +38,11 @@ spawn is a next integration. `JajceWorld` owns reversible High, Balanced, and Low presentation profiles with instance-local mutable environment/grass resources and viewport-scale ownership. -| Profile | 3D scale | Shadow distance | Volumetric/glow | Grass | +| Profile | 3D scale / MSAA | Shadow distance | Volumetric/glow | Grass | | --- | ---: | ---: | --- | --- | -| High | authored | authored (currently 180 m) | authored when renderer supports it | authored density/interactors | -| Balanced (default) | 0.85 cap | 120 m cap | authored ordinary lookdev; unsupported volumetric disabled | reduced spacing/interactors/update rate | -| Low | 0.70 cap | 80 m, orthogonal | volumetric/glow/adjustment disabled; ordinary fog retained | hidden/stopped, emitters off, shader interactors zero | +| High | authored / authored 4× | authored (currently 180 m) | authored when renderer supports it | authored density/interactors | +| Balanced (default) | 0.85 cap / 2× cap | 120 m cap | authored ordinary lookdev; unsupported volumetric disabled | reduced spacing/interactors/update rate | +| Low | 0.70 cap / off | 80 m, orthogonal | volumetric/glow/adjustment disabled; ordinary fog retained | hidden/stopped, emitters off, shader interactors zero | UI remains at native resolution. High/Low switches restore captured authored values; viewport scale restoration is guarded by the current quality owner so a @@ -103,7 +103,7 @@ profession tint, velocity-driven motion, inventory cues and death through the existing presentation controllers. No saved state or navigation changes. Characters and buildings use soft cel bands; tree crowns retain the shared -breeze with painted color patches. A static procedural cloud sky, cooler +breeze with painted color patches. A painted cloud panorama, cooler ambient fill and warm sunlight follow the simulation day/night clock. The cloud shader adds no volumetric pass, outline pass or screen-space postprocess. See [the asset contract and rebuild commands](../assets/storybook/README.md). @@ -161,3 +161,85 @@ ledger, not brittle cross-machine CI thresholds. See [the benchmark ledger](benchmarks/README.md), [regional contract](REGIONAL_SIMULATION.md), and [the build-in-public visual plan](BUILD_IN_PUBLIC_PLAN.md). + +## Painterly environment pass — September 2026 + +The reference study highlighted flat cloud bands, primitive tree silhouettes, +sparse ground cover, opaque green water, and a sun arc offset from daytime +brightness. The current pass addresses those through shared presentation assets: + +- The WorldEnvironment uses cool blue ambient fill, warm sunlight, restrained + glow and ordinary aerial fog. The solar arc now peaks at noon and the warm + transitions overlap dawn/dusk. An original painted cloud panorama supplies + layered cumulus shapes; a static sky shader remaps the artwork, blends its + seam/horizon, and tints it from the simulation clock. It adds no TIME-driven + cubemap updates or volumetric cloud pass. Generation provenance and the exact + prompt are in [the asset guide](../assets/painterly/README.md). +- Original Blender broadleaf crowns/trunks and layered conifers replace the + primitive foliage. Seventeen decorative edge trees form a woodland backdrop. + The four village tree placeholders now use `HarvestableTreePresentation`, + retaining their existing IDs, amounts, interaction points and resource count. + Full/low/depleted state and restored crowns still derive from real amounts. +- `PainterlyMeadow` creates at most 1,680 decorative flower clumps in four + MultiMesh batches, with cream daisies, pink cosmos, buttercups and lavender. + Local seeded drifts follow terrain and exclude houses, paths, work sites and + resource approach points. High/Balanced/Low expose at most 1,680/1,092/536 + clumps, with shorter distance fading in cheaper profiles. Flowers share the + prevailing breeze; the existing grass and berry contact field remains intact. + Explicit white instance colors preserve the painted GLB palette when custom + instance data is enabled in Compatibility; native review caught and verified + that renderer-specific regression. + Flowers have no collision, harvesting, actor-contact deformation or saved + state. See [mesh sources and budgets](../assets/meadow/README.md). +- The blue/turquoise river follows actual centerline terrain heights, keeps + level cross-sections, and clips dry edges at the shoreline. The pool and its + outlet meet without overlapping coplanar transparent sheets. Broken foam, + subtle flowing strokes and painted reflection patches replace the hard ring + and featureless surface. These reflections are stylized procedural patches + plus ordinary sky specular response, not exact reflected scene geometry, + screen-space reflections, refraction or depth sampling. Water obeys scene + lighting at night and adds no per-frame scripts. +- MSAA follows the existing viewport ownership rules: High preserves authored + 4×, Balanced caps at 2×, and Low disables it. Closing an inactive world cannot + overwrite the active world's antialiasing or an external viewport override. + +The editable Blender sources live under `art/painterly/` and `art/meadow/`, +protected by `.gdignore`; runtime consumes portable GLBs. The broadleaf is +5,350 triangles / five surfaces and the conifer is 1,580 / two. All flower +clumps together are capped at 362,880 triangles / four surfaces before +quality and distance reduction. No scene collision, navigation bake, terrain +heightmap, simulation rule or save schema was changed. + +`tools/capture_painterly_environment.gd -- --label=after` captures the running +main scene with paused simulation, fixed daytime presentation and hidden HUD, +including the normal gameplay camera, valley, lower village view and night. +The valley/near-village `painterly_before_*` and `painterly_after_*` +images are matched cameras; `after_gameplay` fixes the normal rig at its default +yaw/zoom with mouse input disabled, and `after_vista` is an additional review angle. +The tool also records static-camera profile samples in +`painterly_after_metrics.json`; wall-frame latency includes vsync and is not +isolated GPU time or weak-PC proof. Runtime camera defaults are unchanged. + +Remaining visual work is primarily authored terrain/shoreline detail and the +blockout-like building/fortress forms. This pass improves atmosphere and +foliage without treating those assets as finished film-quality environments. + +Validation for this pass: the full macOS Godot 4.7 gate passed, including all +headless scenarios, the Compatibility quality scenario, and 182 GUT tests / +2,279 assertions. Main-scene headless startup passed the gate's existing exact +diagnostic allowlist. Native Metal daytime/gameplay/valley/night images were +reviewed at 1600×900, with a separate native Compatibility capture confirming +colored petals after the instance-buffer fix. The presentation-quality scenario +also passed with native Compatibility, including white instance-color readback +(the headless dummy renderer cannot provide that readback). +The static M1 Max sample reports approximately 16.7 ms +median wall-frame time in all three profiles; see its JSON for p95, draw calls +and primitives. This includes vsync and does not establish GPU headroom. + +Native renderer shutdown still reports the particle shader/material RID +retention previously documented in the storybook/foliage pass; the headless +allowlist was not expanded. Compatibility has different color/tonemapping and +its existing particle/texture shutdown diagnostics. Metal is the color +reference for the saved art-review images. +The native multi-world quality scenario additionally logged a transient macOS +OpenGL cubemap-unloadable diagnostic; the final main-scene captures did not. diff --git a/docs/baselines/painterly_after_gameplay.png b/docs/baselines/painterly_after_gameplay.png new file mode 100644 index 0000000..f90f019 Binary files /dev/null and b/docs/baselines/painterly_after_gameplay.png differ diff --git a/docs/baselines/painterly_after_meadow.png b/docs/baselines/painterly_after_meadow.png new file mode 100644 index 0000000..ab6864f Binary files /dev/null and b/docs/baselines/painterly_after_meadow.png differ diff --git a/docs/baselines/painterly_after_metrics.json b/docs/baselines/painterly_after_metrics.json new file mode 100644 index 0000000..f6171cd --- /dev/null +++ b/docs/baselines/painterly_after_metrics.json @@ -0,0 +1,53 @@ +{ + "device": "Apple M1 Max (Apple7)", + "renderer": "metal", + "resolution": "1600x900", + "samples": [ + { + "active_npcs": 6, + "draw_calls": 825.0, + "meadow": { + "flower_clump_count": 1680, + "multimesh_batch_count": 4, + "visible_flower_clump_count": 1680 + }, + "msaa_enum": 2, + "primitives": 1070038.0, + "profile": "high", + "render_scale": 1.0, + "wall_frame_p50_ms": 16.649, + "wall_frame_p95_ms": 17.327 + }, + { + "active_npcs": 6, + "draw_calls": 710.0, + "meadow": { + "flower_clump_count": 1680, + "multimesh_batch_count": 4, + "visible_flower_clump_count": 1092 + }, + "msaa_enum": 1, + "primitives": 810830.0, + "profile": "balanced", + "render_scale": 0.850000023841858, + "wall_frame_p50_ms": 16.672, + "wall_frame_p95_ms": 17.06 + }, + { + "active_npcs": 6, + "draw_calls": 657.0, + "meadow": { + "flower_clump_count": 1680, + "multimesh_batch_count": 4, + "visible_flower_clump_count": 536 + }, + "msaa_enum": 0, + "primitives": 645010.0, + "profile": "low", + "render_scale": 0.699999988079071, + "wall_frame_p50_ms": 16.676, + "wall_frame_p95_ms": 17.069 + } + ], + "workload": "Paused main scene, static valley camera; wall time includes vsync" +} diff --git a/docs/baselines/painterly_after_night.png b/docs/baselines/painterly_after_night.png new file mode 100644 index 0000000..55ba167 Binary files /dev/null and b/docs/baselines/painterly_after_night.png differ diff --git a/docs/baselines/painterly_after_valley.png b/docs/baselines/painterly_after_valley.png new file mode 100644 index 0000000..632fa94 Binary files /dev/null and b/docs/baselines/painterly_after_valley.png differ diff --git a/docs/baselines/painterly_after_vista.png b/docs/baselines/painterly_after_vista.png new file mode 100644 index 0000000..43824fc Binary files /dev/null and b/docs/baselines/painterly_after_vista.png differ diff --git a/docs/baselines/painterly_before_meadow.png b/docs/baselines/painterly_before_meadow.png new file mode 100644 index 0000000..44e9703 Binary files /dev/null and b/docs/baselines/painterly_before_meadow.png differ diff --git a/docs/baselines/painterly_before_valley.png b/docs/baselines/painterly_before_valley.png new file mode 100644 index 0000000..2499768 Binary files /dev/null and b/docs/baselines/painterly_before_valley.png differ diff --git a/docs/baselines/painterly_compatibility_meadow.png b/docs/baselines/painterly_compatibility_meadow.png new file mode 100644 index 0000000..852b319 Binary files /dev/null and b/docs/baselines/painterly_compatibility_meadow.png differ diff --git a/docs/baselines/painterly_compatibility_metrics.json b/docs/baselines/painterly_compatibility_metrics.json new file mode 100644 index 0000000..a231169 --- /dev/null +++ b/docs/baselines/painterly_compatibility_metrics.json @@ -0,0 +1,53 @@ +{ + "device": "Apple M1 Max", + "renderer": "opengl3", + "resolution": "1600x900", + "samples": [ + { + "active_npcs": 6, + "draw_calls": 1304.0, + "meadow": { + "flower_clump_count": 1680, + "multimesh_batch_count": 4, + "visible_flower_clump_count": 1680 + }, + "msaa_enum": 2, + "primitives": 1070038.0, + "profile": "high", + "render_scale": 1.0, + "wall_frame_p50_ms": 22.166, + "wall_frame_p95_ms": 23.467 + }, + { + "active_npcs": 6, + "draw_calls": 1166.0, + "meadow": { + "flower_clump_count": 1680, + "multimesh_batch_count": 4, + "visible_flower_clump_count": 1092 + }, + "msaa_enum": 1, + "primitives": 810758.0, + "profile": "balanced", + "render_scale": 0.850000023841858, + "wall_frame_p50_ms": 17.903, + "wall_frame_p95_ms": 26.363 + }, + { + "active_npcs": 6, + "draw_calls": 1084.0, + "meadow": { + "flower_clump_count": 1680, + "multimesh_batch_count": 4, + "visible_flower_clump_count": 536 + }, + "msaa_enum": 0, + "primitives": 645010.0, + "profile": "low", + "render_scale": 0.699999988079071, + "wall_frame_p50_ms": 13.533, + "wall_frame_p95_ms": 16.623 + } + ], + "workload": "Paused main scene, static valley camera; wall time includes vsync" +} diff --git a/docs/baselines/painterly_compatibility_night.png b/docs/baselines/painterly_compatibility_night.png new file mode 100644 index 0000000..8db34f7 Binary files /dev/null and b/docs/baselines/painterly_compatibility_night.png differ diff --git a/project.godot b/project.godot index 7796ff1..b82ba54 100644 --- a/project.godot +++ b/project.godot @@ -32,6 +32,10 @@ window/size/viewport_height=1020 enabled=PackedStringArray("res://addons/terrain_3d/plugin.cfg", "res://addons/gut/plugin.cfg", "res://addons/dialogue_manager/plugin.cfg") +[rendering] + +anti_aliasing/quality/msaa_3d=2 + [input] move_forward={ diff --git a/tests/jajce_presentation_quality_test.gd b/tests/jajce_presentation_quality_test.gd index 40c2a5b..3e4a4b3 100644 --- a/tests/jajce_presentation_quality_test.gd +++ b/tests/jajce_presentation_quality_test.gd @@ -8,6 +8,8 @@ func _initialize() -> void: func _run() -> void: + var viewport := root as Viewport + var original_msaa := viewport.msaa_3d var camera := Camera3D.new() camera.current = true root.add_child(camera) @@ -16,13 +18,14 @@ func _run() -> void: await process_frame await physics_frame - var viewport := root as Viewport var environment: Environment = ( (world.get_node("WorldEnvironment") as WorldEnvironment).environment ) var light := world.get_node("DirectionalLight3D") as DirectionalLight3D var grass := world.get_node("TerrainRoot/Terrain3D/CozyGrassField") as Node3D var grass_controller := world.get_node("TerrainRoot/GrassInteractionController") as Node + var meadow := world.get_node("PainterlyMeadow") as PainterlyMeadow + var balanced_meadow := meadow.get_presentation_stats() var world_grass_material := grass.get("mesh_material_override") as ShaderMaterial var world_process_material := grass.get("process_material") as ShaderMaterial var world_sky: Sky = environment.sky @@ -41,6 +44,10 @@ func _run() -> void: + " (found %.2f)" % balanced_render_scale ) ) + _check( + viewport.msaa_3d == mini(original_msaa, Viewport.MSAA_2X), + "Balanced should cap authored multisample antialiasing at 2x" + ) _check( ( light.directional_shadow_max_distance <= JajceWorld.BALANCED_SHADOW_DISTANCE @@ -54,6 +61,14 @@ func _run() -> void: world_grass_material == grass_controller.get("grass_material"), "Grass rendering and interaction should share this world's isolated material" ) + _check( + ( + int(balanced_meadow["multimesh_batch_count"]) == 4 + and int(balanced_meadow["flower_clump_count"]) in range(1, 1801) + and _meadow_has_finite_placements(meadow) + ), + "Meadow batches should retain neutral instance colors, finite placement and their clump budget" + ) _check( world.apply_presentation_quality(JajceWorld.PresentationQuality.HIGH), @@ -63,6 +78,21 @@ func _run() -> void: var high_particle_count := int(grass.get("particle_count")) var high_render_scale := viewport.scaling_3d_scale var high_shadow_distance := light.directional_shadow_max_distance + _check( + viewport.msaa_3d == original_msaa, + "High should restore the authored multisample antialiasing setting" + ) + var high_meadow := meadow.get_presentation_stats() + _check( + ( + int(high_meadow["visible_flower_clump_count"]) == int(high_meadow["flower_clump_count"]) + and ( + int(balanced_meadow["visible_flower_clump_count"]) + < int(high_meadow["visible_flower_clump_count"]) + ) + ), + "High should restore every flower clump while Balanced reduces meadow density" + ) _check( high_particle_count in range(9000, 15001), "High should restore the authored grass population" @@ -94,6 +124,9 @@ func _run() -> void: viewport.scaling_3d_scale <= JajceWorld.LOW_RENDER_SCALE + 0.001, "Low should reduce 3D render resolution" ) + _check( + viewport.msaa_3d == Viewport.MSAA_DISABLED, "Low should disable multisample antialiasing" + ) _check( ( environment.fog_enabled @@ -120,6 +153,18 @@ func _run() -> void: ) _check(not grass_controller.is_processing(), "Low should stop grass interaction scans") _check(_not_any_grass_particle_emitting(grass), "Low should stop every grass particle emitter") + var low_meadow := meadow.get_presentation_stats() + _check( + ( + int(low_meadow["visible_flower_clump_count"]) > 0 + and ( + int(low_meadow["visible_flower_clump_count"]) + < int(balanced_meadow["visible_flower_clump_count"]) + ) + and low_meadow["flower_clump_count"] == high_meadow["flower_clump_count"] + ), + "Low should retain a reduced flower population without regenerating placements" + ) _check( ( world_grass_material != null @@ -136,6 +181,7 @@ func _run() -> void: _check( ( is_equal_approx(viewport.scaling_3d_scale, high_render_scale) + and viewport.msaa_3d == original_msaa and environment.glow_enabled and (environment.volumetric_fog_enabled == world.call("_supports_volumetric_fog")) and environment.adjustment_enabled @@ -153,12 +199,19 @@ func _run() -> void: ), "Returning to High should restore grass rendering and processing" ) + _check( + meadow.get_presentation_stats() == high_meadow, + "Returning to High should restore the same complete flower batches" + ) var world_base_scale := float(world.get("_authored_render_scale")) world.free() _check( - is_equal_approx(viewport.scaling_3d_scale, world_base_scale), - "An exiting world should restore the viewport scale it originally owned" + ( + is_equal_approx(viewport.scaling_3d_scale, world_base_scale) + and viewport.msaa_3d == original_msaa + ), + "An exiting world should restore the viewport scale and antialiasing it originally owned" ) _check( ( @@ -183,6 +236,7 @@ func _run() -> void: _check( ( fresh_world.get_active_presentation_quality() == JajceWorld.PresentationQuality.BALANCED + and viewport.msaa_3d == mini(original_msaa, Viewport.MSAA_2X) and fresh_environment.fog_enabled and not fresh_environment.volumetric_fog_enabled and fresh_grass.visible @@ -205,8 +259,11 @@ func _run() -> void: var original_scale := float(fresh_world.get("_authored_render_scale")) fresh_world.free() _check( - is_equal_approx(viewport.scaling_3d_scale, original_scale), - "A sequential world should release its viewport-scale ownership" + ( + is_equal_approx(viewport.scaling_3d_scale, original_scale) + and viewport.msaa_3d == original_msaa + ), + "A sequential world should release its viewport scale and antialiasing ownership" ) var older_owner := _create_presentation_only_world(JajceWorld.PresentationQuality.LOW) @@ -216,19 +273,29 @@ func _run() -> void: root.add_child(newer_owner) await process_frame _check( - is_equal_approx(float(newer_owner.get("_authored_render_scale")), original_scale), - "Concurrent owners should inherit the viewport base, not another owner's reduced scale" + ( + is_equal_approx(float(newer_owner.get("_authored_render_scale")), original_scale) + and int(newer_owner.get("_authored_msaa")) == original_msaa + ), + "Concurrent owners should inherit the viewport's base scale and antialiasing" ) older_owner.free() _check( - is_equal_approx(viewport.scaling_3d_scale, JajceWorld.BALANCED_RENDER_SCALE), - "A stale owner exiting should not overwrite the active owner's scale" + ( + is_equal_approx(viewport.scaling_3d_scale, JajceWorld.BALANCED_RENDER_SCALE) + and viewport.msaa_3d == mini(original_msaa, Viewport.MSAA_2X) + ), + "A stale owner exiting should preserve the active owner's scale and antialiasing" ) newer_owner.free() _check( - is_equal_approx(viewport.scaling_3d_scale, original_scale), - "The final viewport owner should restore the original scale on exit" + ( + is_equal_approx(viewport.scaling_3d_scale, original_scale) + and viewport.msaa_3d == original_msaa + ), + "The final viewport owner should restore the original scale and antialiasing on exit" ) + await _check_msaa_previous_owner_and_external_change(viewport) var main_scene: Node = load("res://main.tscn").instantiate() var time_dial := main_scene.get_node("UI/TimeDial") as Control @@ -264,6 +331,32 @@ func _all_grass_particles_emitting(grass: Node3D) -> bool: return true +func _meadow_has_finite_placements(meadow: PainterlyMeadow) -> bool: + var terrain := meadow.get_node(meadow.terrain_path) as Terrain3D + # The dummy headless renderer returns black for every instance-color readback. + var can_read_instance_colors := DisplayServer.get_name() != "headless" + for child in meadow.get_children(): + var batch := child as MultiMeshInstance3D + if batch == null or batch.multimesh == null: + return false + if not batch.multimesh.use_colors or not batch.multimesh.use_custom_data: + return false + for index in batch.multimesh.instance_count: + if ( + can_read_instance_colors + and not batch.multimesh.get_instance_color(index).is_equal_approx(Color.WHITE) + ): + return false + var placement := batch.multimesh.get_instance_transform(index) + if not placement.is_finite(): + return false + var point := batch.to_global(placement.origin) + var height := terrain.data.get_height(point) + if is_nan(height) or absf(point.y - height) > 0.025: + return false + return true + + func _create_presentation_only_world(quality: int) -> JajceWorld: var world: JajceWorld = load("res://world/jajce/JajceWorld.tscn").instantiate() # Stable simulation-facing IDs deliberately allow only one loaded authority @@ -273,6 +366,33 @@ func _create_presentation_only_world(quality: int) -> JajceWorld: return world +func _check_msaa_previous_owner_and_external_change(viewport: Viewport) -> void: + var original_msaa := viewport.msaa_3d + viewport.msaa_3d = Viewport.MSAA_8X + var previous_owner := _create_presentation_only_world(JajceWorld.PresentationQuality.LOW) + root.add_child(previous_owner) + await process_frame + var active_owner := _create_presentation_only_world(JajceWorld.PresentationQuality.HIGH) + root.add_child(active_owner) + await process_frame + _check( + viewport.msaa_3d == Viewport.MSAA_8X, + "A High owner should inherit authored antialiasing even when the previous owner uses Low" + ) + active_owner.free() + _check( + viewport.msaa_3d == Viewport.MSAA_DISABLED, + "An exiting active owner should restore the surviving previous owner's antialiasing" + ) + viewport.msaa_3d = Viewport.MSAA_2X + previous_owner.free() + _check( + viewport.msaa_3d == Viewport.MSAA_2X, + "Releasing the final owner should preserve an external antialiasing change" + ) + viewport.msaa_3d = original_msaa + + func _not_any_grass_particle_emitting(grass: Node3D) -> bool: var particles: Array = grass.get("particle_nodes") for value in particles: diff --git a/tests/jajce_resource_presentation_state_test.gd b/tests/jajce_resource_presentation_state_test.gd index 4a80b03..a0d3572 100644 --- a/tests/jajce_resource_presentation_state_test.gd +++ b/tests/jajce_resource_presentation_state_test.gd @@ -20,6 +20,7 @@ func _run() -> void: var manager = main_scene.get_node("SimulationManager") manager.set_process(false) + _check_village_tree_bindings(main_scene, manager) var cluster := main_scene.get_node(CLUSTER_PATH) as RiverbankResourceCluster var berry := cluster.get_node("ResourceAnchors/BerryBush_River_02") as ResourceNode var tree := cluster.get_node("ResourceAnchors/Tree_River_Resource_01") as ResourceNode @@ -120,6 +121,34 @@ func _run() -> void: _finish() +func _check_village_tree_bindings(main_scene: Node, manager: Node) -> void: + var snapshot: String = manager.serialize_state() + for tree_name in ["Tree_01", "Tree_02", "Tree_North_Outskirts_01", "Tree_South_Edge_01"]: + var tree := ( + main_scene.get_node("JajceWorld/WorldObjects/ResourceNodes/" + tree_name) + as ResourceNode + ) + var visual := tree.get_node("Visual/TreePresentation") as HarvestableTreePresentation + _check( + visual.resource_node == tree, + "Village tree art should bind the existing finite resource" + ) + while tree.state.can_extract(): + manager.harvest_resource_node(tree) + _check(visual.is_stump_visible(), "Harvesting the real village tree should leave a stump") + _check(manager.restore_state_from_json(snapshot), "Village tree harvest review should restore") + for tree_name in ["Tree_01", "Tree_02", "Tree_North_Outskirts_01", "Tree_South_Edge_01"]: + var visual := ( + main_scene.get_node( + "JajceWorld/WorldObjects/ResourceNodes/" + tree_name + "/Visual/TreePresentation" + ) + as HarvestableTreePresentation + ) + _check( + visual.get_visible_canopy_count() == 4, "Restore rebuilds each full village tree crown" + ) + + func _check_initial_presentation( berry_visual: StylizedBerryPatch, tree_visual: HarvestableTreePresentation ) -> void: diff --git a/tests/jajce_watercourse_terrain_test.gd b/tests/jajce_watercourse_terrain_test.gd index d513cfd..a0e9902 100644 --- a/tests/jajce_watercourse_terrain_test.gd +++ b/tests/jajce_watercourse_terrain_test.gd @@ -63,10 +63,13 @@ func _run() -> void: var river_surface := world.get_node("WaterRoot/RiverSurface") as Node3D _check_ribbon_tracks_terrain( - terrain, river_surface.get_node("RiverRibbon") as MeshInstance3D, "Downstream water" + terrain, river_surface.get_node("RiverRibbon") as MeshInstance3D, "Downstream water", false ) _check_ribbon_tracks_terrain( - terrain, river_surface.get_node("UpperRiverRibbon") as MeshInstance3D, "Upper-stream water" + terrain, + river_surface.get_node("UpperRiverRibbon") as MeshInstance3D, + "Upper-stream water", + true ) print( @@ -83,23 +86,98 @@ func _terrain_height(terrain: Terrain3D, world_x: float, world_z: float) -> floa func _check_ribbon_tracks_terrain( - terrain: Terrain3D, ribbon: MeshInstance3D, label: String + terrain: Terrain3D, ribbon: MeshInstance3D, label: String, is_upper: bool ) -> void: _check(ribbon != null and ribbon.mesh is ArrayMesh, "%s ribbon should be generated" % label) if ribbon == null or not ribbon.mesh is ArrayMesh: return var arrays := ribbon.mesh.surface_get_arrays(0) var vertices: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX] + var uvs: PackedVector2Array = arrays[Mesh.ARRAY_TEX_UV] _check(not vertices.is_empty(), "%s ribbon should contain vertices" % label) - for vertex_index in range(0, vertices.size(), maxi(1, vertices.size() / 12)): + if vertices.is_empty() or uvs.size() != vertices.size(): + failures.append("%s ribbon requires a complete coordinate profile" % label) + return + var start_z := ( + JajceWatercourse.UPPER_STREAM_START_Z if is_upper else JajceWatercourse.DOWNSTREAM_START_Z + ) + var end_z := ( + JajceWatercourse.UPPER_STREAM_END_Z if is_upper else JajceWatercourse.DOWNSTREAM_END_Z + ) + _check( + ( + absf(ribbon.to_global(vertices[0]).z - start_z) < 0.03 + and absf(ribbon.to_global(vertices[-1]).z - end_z) < 0.03 + ), + "%s ribbon should span its entire authored stream length" % label + ) + var pool := ribbon.get_parent().get_node("PlungePool") as MeshInstance3D + var pool_mesh := pool.mesh as PlaneMesh + var pool_radii := pool_mesh.size * Vector2(pool.scale.x, pool.scale.z) * 0.5 + var min_depth := INF + var max_depth := -INF + for vertex_index in vertices.size(): var world_vertex := ribbon.to_global(vertices[vertex_index]) + var world_z := lerpf(start_z, end_z, uvs[vertex_index].y) + var center_x := ( + JajceWatercourse.upper_stream_center_x(world_z) + if is_upper + else JajceWatercourse.downstream_center_x(world_z) + ) + var half_width := ( + JajceWatercourse.upper_stream_half_width(world_z) + if is_upper + else JajceWatercourse.downstream_half_width(world_z) + ) + if not is_upper: + var pool_progress := (world_z - pool.global_position.z) / pool_radii.y + if absf(pool_progress) < 1.0: + half_width = maxf( + half_width, pool_radii.x * sqrt(1.0 - pool_progress * pool_progress) + ) + var center_height := _terrain_height(terrain, center_x, world_z) var terrain_height := terrain.data.get_height(world_vertex) - if is_nan(terrain_height): + if ( + not world_vertex.is_finite() + or not is_finite(terrain_height) + or not is_finite(center_height) + ): failures.append("%s ribbon sampled outside Terrain3D" % label) return - if absf(world_vertex.y - terrain_height - JajceWatercourse.WATER_SURFACE_OFFSET) > 0.03: - failures.append("%s ribbon should hug the carved terrain" % label) + if absf(world_vertex.y - center_height - JajceWatercourse.WATER_SURFACE_OFFSET) > 0.03: + failures.append("%s cross-section should follow its actual carved centerline" % label) return + if ( + absf(world_vertex.z - world_z) > 0.03 + or absf(world_vertex.x - center_x) > half_width + 0.03 + ): + failures.append( + "%s ribbon should stay inside the authored stream/pool footprint" % label + ) + return + var depth := world_vertex.y - terrain_height + min_depth = minf(min_depth, depth) + max_depth = maxf(max_depth, depth) + if depth < -0.03 or depth > JajceWatercourse.WATER_SURFACE_OFFSET + 0.03: + failures.append("%s surface should remain shallow and above the channel bed" % label) + return + var across := uvs[vertex_index].x + if is_zero_approx(across) or is_equal_approx(across, 1.0): + var edge_distance := absf(world_vertex.x - center_x) + if absf(edge_distance - half_width) > 0.03 and absf(depth) > 0.03: + failures.append( + "%s edges must reach the authored extent or the actual shoreline" % label + ) + return + if ( + (is_zero_approx(across) and world_vertex.x >= center_x) + or (is_equal_approx(across, 1.0) and world_vertex.x <= center_x) + ): + failures.append( + "%s water must retain its authored centerline inside both banks" % label + ) + return + print("[TEST] %s depth bounds | min=%.3f max=%.3f" % [label, min_depth, max_depth]) func _check(condition: bool, message: String) -> void: diff --git a/tests/jajce_world_scaffold_test.gd b/tests/jajce_world_scaffold_test.gd index a18b2a3..83c471e 100644 --- a/tests/jajce_world_scaffold_test.gd +++ b/tests/jajce_world_scaffold_test.gd @@ -198,7 +198,7 @@ func _run() -> void: if not tree.has_node("Canopy"): continue var canopy := tree.get_node("Canopy") as MeshInstance3D - var material := canopy.mesh.material as ShaderMaterial + var material := canopy.get_active_material(0) as ShaderMaterial tree_wind_is_bounded = ( tree_wind_is_bounded and material != null @@ -249,6 +249,8 @@ func _run() -> void: upper_river_ribbon.mesh is ArrayMesh, "Waterfall shelf should expose a terrain-following upper stream" ) + _check_water_cross_sections(river_ribbon) + _check_water_cross_sections(upper_river_ribbon) _check( world.has_node("WaterRoot/RiverSurface/PlungePool"), "River should widen into a readable plunge pool beneath the waterfall" @@ -270,6 +272,7 @@ func _run() -> void: ), "Plunge-pool water should follow the carved basin height" ) + _check_pool_outlet(river_ribbon, plunge_pool, terrain) var environment: Environment = ( (world.get_node("WorldEnvironment") as WorldEnvironment).environment ) @@ -628,6 +631,44 @@ func _run() -> void: quit(1) +func _check_water_cross_sections(ribbon: MeshInstance3D) -> void: + var arrays := ribbon.mesh.surface_get_arrays(0) + var vertices: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX] + var uvs: PackedVector2Array = arrays[Mesh.ARRAY_TEX_UV] + var row_height := 0.0 + for index in vertices.size(): + if is_zero_approx(uvs[index].x): + row_height = vertices[index].y + _check( + is_equal_approx(vertices[index].y, row_height), + "Water cross-sections should remain level instead of climbing either terrain bank" + ) + + +func _check_pool_outlet(ribbon: MeshInstance3D, pool: MeshInstance3D, terrain: Terrain3D) -> void: + var vertices: PackedVector3Array = ribbon.mesh.surface_get_arrays(0)[Mesh.ARRAY_VERTEX] + var start := ribbon.to_global(vertices[0]) + var pool_mesh := pool.mesh as PlaneMesh + var radii := pool_mesh.size * Vector2(pool.scale.x, pool.scale.z) * 0.5 + var progress := (start.z - pool.global_position.z) / radii.y + var expected_edge := pool.global_position.x - radii.x * sqrt(1.0 - progress * progress) + var meets_ellipse := absf(start.x - expected_edge) < 0.03 + var meets_shore := ( + start.x >= expected_edge + and start.x < pool.global_position.x + and absf(start.y - terrain.data.get_height(start)) < 0.03 + ) + _check( + (meets_ellipse or meets_shore) and absf(start.y - pool.global_position.y) < 0.03, + "River outlet should meet the pool ellipse or its terrain shoreline at the same surface height" + ) + var material := pool_mesh.surface_get_material(0) as ShaderMaterial + _check( + is_equal_approx(float(material.get_shader_parameter("pool_outlet_z")), start.z), + "Pool transparency should end where the river takes over to avoid coplanar overlap" + ) + + func _wait_for_navigation_map(navigation_map: RID) -> void: for attempt in 30: if ( diff --git a/tests/unit/test_day_night_presentation.gd b/tests/unit/test_day_night_presentation.gd new file mode 100644 index 0000000..f72d991 --- /dev/null +++ b/tests/unit/test_day_night_presentation.gd @@ -0,0 +1,29 @@ +extends GutTest + +const CYCLE := preload("res://world/jajce/day_night_cycle.gd") + + +func test_sun_is_highest_at_noon_and_lights_the_ground_throughout_day() -> void: + var cycle := CYCLE.new() + var light := DirectionalLight3D.new() + cycle.directional_light = light + cycle._apply_light_rotation(0.5) + var noon_elevation := -light.rotation_degrees.x + assert_gt(noon_elevation, 45.0, "Noon should have an elevated sun, not grazing light") + for time in [0.15, 0.25, 0.4, 0.6, 0.75, 0.85]: + cycle._apply_light_rotation(time) + assert_lt(light.rotation_degrees.x, 0.0, "The daylight source points down toward terrain") + assert_lt(-light.rotation_degrees.x, noon_elevation, "The solar arc peaks at noon") + cycle.free() + light.free() + + +func test_warm_transitions_overlap_dawn_and_dusk_without_persisting_at_noon() -> void: + var cycle := CYCLE.new() + for time in [0.22, 0.78]: + assert_gt(cycle._day_factor(time), 0.0) + assert_lt(cycle._day_factor(time), 1.0) + assert_gt(cycle._sunrise_sunset_weight(time), 0.8) + assert_eq(cycle._sunrise_sunset_weight(0.5), 0.0) + assert_eq(cycle._sunrise_sunset_weight(0.0), 0.0) + cycle.free() diff --git a/tests/unit/test_day_night_presentation.gd.uid b/tests/unit/test_day_night_presentation.gd.uid new file mode 100644 index 0000000..e4997c6 --- /dev/null +++ b/tests/unit/test_day_night_presentation.gd.uid @@ -0,0 +1 @@ +uid://dnjghjrvx52cr diff --git a/tools/art/build_meadow_flowers.py b/tools/art/build_meadow_flowers.py new file mode 100644 index 0000000..350e418 --- /dev/null +++ b/tools/art/build_meadow_flowers.py @@ -0,0 +1,176 @@ +"""Build the original Jajce meadow flowers in Blender 5.1+. + +Blender --background --python tools/art/build_meadow_flowers.py +Each clump has one opaque vertex-colored surface and no textures. Coordinates +are authored Y-up and converted for Blender; GLB export restores Godot Y-up. +The .blend source files are editable and excluded from Godot import. +""" + +import json +import math +from pathlib import Path + +import bpy +from mathutils import Vector + +ROOT = Path(__file__).resolve().parents[2] +SOURCE = ROOT / "art/meadow" +OUTPUT = ROOT / "assets/meadow" +SOURCE.mkdir(parents=True, exist_ok=True) +OUTPUT.mkdir(parents=True, exist_ok=True) + + +def linear(hex_color): + values = [int(hex_color[i:i + 2], 16) / 255 for i in (0, 2, 4)] + return tuple(v / 12.92 if v <= 0.04045 else ((v + 0.055) / 1.055) ** 2.4 for v in values) + + +def coord(point): + return (point[0], -point[2], point[1]) + + +class FlowerMesh: + def __init__(self): + self.vertices = [] + self.faces = [] + self.colors = [] + + def face(self, points, color): + first = len(self.vertices) + self.vertices.extend(coord(point) for point in points) + self.faces.append(tuple(range(first, first + len(points)))) + self.colors.extend([(*linear(color), 1.0)] * len(points)) + + def stem(self, base, top): + bottom = Vector(base) + tip = Vector(top) + middle = bottom.lerp(tip, 0.55) + Vector((0.015, 0.0, -0.018)) + for start, end in ((bottom, middle), (middle, tip)): + for side in range(4): + a = side * math.tau / 4 + b = (side + 1) * math.tau / 4 + edge_a = Vector((math.cos(a), 0, math.sin(a))) * 0.009 + edge_b = Vector((math.cos(b), 0, math.sin(b))) * 0.009 + self.face((start + edge_a, start + edge_b, end + edge_b * 0.5, end + edge_a * 0.5), "54864b") + + def leaf(self, base, angle, length=0.18, width=0.042): + root = Vector(base) + direction = Vector((math.cos(angle), 0.6, math.sin(angle))) * length + side = Vector((-math.sin(angle), 0, math.cos(angle))) * width + middle = root + direction * 0.52 + tip = root + direction + # Raised crease gives a leaf silhouette from both ground and game camera. + crease = middle + Vector((0, 0.028, 0)) + self.face((root, middle + side, crease), "6fa74f") + self.face((middle + side, tip, crease), "80af58") + self.face((root, crease, middle - side), "4f8548") + self.face((middle - side, crease, tip), "679c4e") + + def blossom(self, center, radius, petals, color, highlight, heart="e6b344"): + center = Vector(center) + for petal in range(petals): + angle = petal * math.tau / petals + 0.12 + forward = Vector((math.cos(angle), 0, math.sin(angle))) + side = Vector((-math.sin(angle), 0, math.cos(angle))) + root = center + forward * radius * 0.12 + middle = center + forward * radius * 0.65 + Vector((0, radius * 0.09, 0)) + tip = center + forward * radius + Vector((0, radius * 0.24, 0)) + width = radius * (0.30 if petals < 7 else 0.23) + self.face((root, middle + side * width, tip + side * width * 0.42), color) + self.face((root, tip + side * width * 0.42, tip - side * width * 0.42), highlight) + self.face((root, tip - side * width * 0.42, middle - side * width), color) + # An opaque domed pollen heart rather than a texture or billboard. + for side in range(8): + a = side * math.tau / 8 + b = (side + 1) * math.tau / 8 + self.face((center + Vector((0, radius * 0.24, 0)), + center + Vector((math.cos(a) * radius * 0.24, 0.008, math.sin(a) * radius * 0.24)), + center + Vector((math.cos(b) * radius * 0.24, 0.008, math.sin(b) * radius * 0.24))), heart) + + def build(self, name): + mesh = bpy.data.meshes.new(name) + mesh.from_pydata(self.vertices, [], self.faces) + mesh.update() + attr = mesh.color_attributes.new(name="Color", type="FLOAT_COLOR", domain="CORNER") + for index, loop in enumerate(mesh.loops): + attr.data[index].color = self.colors[loop.vertex_index] + mesh.materials.append(bpy.data.materials["MeadowPalette"]) + obj = bpy.data.objects.new(name, mesh) + bpy.context.collection.objects.link(obj) + return obj + + +def clear_scene(): + bpy.ops.object.select_all(action="SELECT") + bpy.ops.object.delete(use_global=False) + + +def create_material(): + material = bpy.data.materials.new("MeadowPalette") + material.use_nodes = True + material.use_backface_culling = False + nodes = material.node_tree.nodes + nodes.clear() + color = nodes.new("ShaderNodeVertexColor") + color.layer_name = "Color" + surface = nodes.new("ShaderNodeBsdfPrincipled") + surface.inputs["Roughness"].default_value = 1.0 + material.node_tree.links.new(color.outputs["Color"], surface.inputs["Base Color"]) + output = nodes.new("ShaderNodeOutputMaterial") + material.node_tree.links.new(surface.outputs["BSDF"], output.inputs["Surface"]) + + +def flower(name, colors, petals, height, radius): + clear_scene() + builder = FlowerMesh() + for index, (x, z, scale) in enumerate(((0, 0, 1.0), (-0.16, 0.1, 0.76), (0.16, -0.1, 0.88))): + top = (x + 0.035 * math.cos(index * 2.2), height * scale, z + 0.04) + builder.stem((x, 0, z), top) + for leaf in range(3): + y = height * scale * (0.17 + leaf * 0.16) + builder.leaf((x, y, z), index * 2.1 + leaf * 2.45, 0.17 * scale, 0.034) + if name == "lavender_spire": + for tier in range(4): + pos = (top[0], top[1] - tier * 0.086, top[2]) + builder.blossom(pos, radius * (0.58 + tier * 0.13), 5, *colors, heart="c7b3df") + else: + builder.blossom(top, radius * scale, petals, *colors) + obj = builder.build(name) + bpy.ops.object.select_all(action="DESELECT") + obj.select_set(True) + bpy.context.view_layer.objects.active = obj + bpy.ops.export_scene.gltf( + filepath=str(OUTPUT / (name + ".glb")), export_format="GLB", + use_selection=True, export_yup=True, export_animations=False, + export_vertex_color="ACTIVE", export_all_vertex_colors=False, + export_materials="EXPORT", export_extras=False, export_cameras=False, + ) + for screen in bpy.data.screens: + for area in screen.areas: + if area.type == "VIEW_3D": + area.spaces.active.shading.type = "MATERIAL" + bpy.context.preferences.filepaths.save_version = 0 + bpy.ops.wm.save_as_mainfile(filepath=str(SOURCE / (name + ".blend"))) + obj.data.calc_loop_triangles() + return { + "triangles": len(obj.data.loop_triangles), "surfaces": 1, + "flower_stems": 3, "bytes": (OUTPUT / (name + ".glb")).stat().st_size, + } + + +def main(): + clear_scene() + create_material() + specs = ( + ("cream_daisy", ("f0e7c4", "fff4d8"), 8, 0.47, 0.125), + ("pink_cosmos", ("d780a7", "f1abc4"), 7, 0.64, 0.165), + ("yellow_buttercup", ("edc45a", "ffdf83"), 5, 0.36, 0.10), + ("lavender_spire", ("9585b8", "bfafd6"), 5, 0.79, 0.080), + ) + stats = {spec[0]: flower(*spec) for spec in specs} + (OUTPUT / "mesh_budget.json").write_text(json.dumps(stats, indent=2) + "\n") + print(json.dumps(stats, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/tools/art/build_painterly_trees.py b/tools/art/build_painterly_trees.py new file mode 100644 index 0000000..91442db --- /dev/null +++ b/tools/art/build_painterly_trees.py @@ -0,0 +1,267 @@ +"""Build original scalloped tree meshes, editable in Blender and portable to Godot. + +Run: Blender --background --python tools/art/build_painterly_trees.py +One crown surface and one joined trunk/branch surface; no textures or alpha cards. +All coordinates use Blender Z-up and glTF exports them as Godot Y-up. +""" + +import json +import math +import random +from pathlib import Path + +import bpy +from mathutils import Vector + +ROOT = Path(__file__).resolve().parents[2] +SOURCE = ROOT / "art/painterly" +OUTPUT = ROOT / "assets/painterly" +SOURCE.mkdir(parents=True, exist_ok=True) +OUTPUT.mkdir(parents=True, exist_ok=True) +(SOURCE / ".gdignore").write_text("") +RNG = random.Random(2741) + +bpy.ops.object.select_all(action="SELECT") +bpy.ops.object.delete(use_global=False) + + +def material(name): + mat = bpy.data.materials.new(name) + mat.use_nodes = True + nodes = mat.node_tree.nodes + shader = nodes.get("Principled BSDF") + shader.inputs["Roughness"].default_value = 0.95 + shader.inputs["Specular IOR Level"].default_value = 0.0 + color = nodes.new("ShaderNodeVertexColor") + color.layer_name = "Color" + mat.node_tree.links.new(color.outputs["Color"], shader.inputs["Base Color"]) + return mat + + +def mesh_object(name, vertices, faces, colors, mat): + mesh = bpy.data.meshes.new(name) + mesh.from_pydata(vertices, [], faces) + mesh.update() + obj = bpy.data.objects.new(name, mesh) + bpy.context.collection.objects.link(obj) + attr = mesh.color_attributes.new(name="Color", type="FLOAT_COLOR", domain="POINT") + for entry, color in zip(attr.data, colors): + entry.color = (*color, 1.0) + mesh.materials.append(mat) + for polygon in mesh.polygons: + polygon.use_smooth = True + return obj + + +def source_tint(mat, tint): + """Set an editing preview tint after portable glTF export.""" + nodes = mat.node_tree.nodes + mix = nodes.new("ShaderNodeMixRGB") + mix.blend_type = "MULTIPLY" + mix.inputs[0].default_value = 1.0 + mix.inputs[2].default_value = (*tint, 1.0) + mat.node_tree.links.new(nodes.get("Color Attribute").outputs["Color"], mix.inputs[1]) + mat.node_tree.links.new(mix.outputs[0], nodes.get("Principled BSDF").inputs["Base Color"]) + + +# Connected, irregular crown with broad painted lobes and small leafy tips. +# Perturbing an envelope keeps the silhouette solid and removes internal overdraw. +bumps = [] +for index in range(23): + z = 1 - 2 * (index + 0.5) / 23 + angle = index * 2.399963 + 0.2 + direction = Vector((math.sqrt(1 - z * z) * math.cos(angle), + math.sqrt(1 - z * z) * math.sin(angle), z)) + bumps.append((direction, RNG.uniform(0.12, 0.25))) + + +def envelope(direction): + ridges = sum(height * math.exp((direction.dot(axis) - 1) * 30) + for axis, height in bumps) + fine = math.sin(direction.x * 22 + direction.z * 9) * math.sin(direction.y * 19) * 0.018 + radius = 0.84 + ridges + fine + return Vector((direction.x * radius, direction.y * radius * 0.95, + direction.z * radius * 0.86)) + + +def pigment(point): + broad = math.sin(point.x * 8 + point.z * 2) * math.sin(point.y * 7 - point.z * 5) + light = 0.82 + 0.11 * broad + 0.07 * max(0, point.z) + return (light * (1.0 + 0.035 * broad), light, light * (0.95 - 0.025 * broad)) + + +vertices, faces, colors = [], [], [] +segments, rings = 32, 16 +vertices.append(envelope(Vector((0, 0, 1)))) +for ring in range(1, rings): + phi = math.pi * ring / rings + for segment in range(segments): + theta = math.tau * segment / segments + direction = Vector((math.sin(phi) * math.cos(theta), math.sin(phi) * math.sin(theta), math.cos(phi))) + vertices.append(envelope(direction)) +vertices.append(envelope(Vector((0, 0, -1)))) +for segment in range(segments): + faces.append((0, 1 + segment, 1 + (segment + 1) % segments)) +for ring in range(rings - 2): + for segment in range(segments): + a = 1 + ring * segments + segment + b = 1 + ring * segments + (segment + 1) % segments + faces.append((a, a + segments, b + segments, b)) +last = len(vertices) - 1 +for segment in range(segments): + faces.append((last, last - segments + (segment + 1) % segments, last - segments + segment)) +colors = [pigment(point) for point in vertices] + +# Bent solid leaf tips give a scalloped edge at close range. They are tiny opaque +# wedges with both sides, so they do not require cull-disabled or alpha materials. +for axis, _height in bumps: + for leaf in range(2): + tangent = axis.cross(Vector((0, 0, 1))) + if tangent.length < 0.01: + tangent = Vector((1, 0, 0)) + tangent.normalize() + sideways = axis.cross(tangent).normalized() + direction = (axis + tangent * (leaf - 0.5) * 0.13).normalized() + base = envelope(direction) * 0.98 + length = RNG.uniform(0.11, 0.17) + width = RNG.uniform(0.04, 0.065) + middle = base + direction * length * 0.55 + start = len(vertices) + vertices.extend([base, middle + tangent * width, middle + sideways * width * 0.28, + middle - tangent * width, base + direction * length]) + faces.extend([tuple(start + j for j in indices) for indices in + ((0, 1, 2), (0, 2, 3), (1, 4, 2), (2, 4, 3), (0, 3, 1), (1, 3, 4))]) + colors.extend([pigment(point) for point in vertices[-5:]]) +canopy = mesh_object("Canopy", vertices, faces, colors, material("CanopyPigment")) + +# A crooked trunk, joined forks and roots use one surface. Colors are linear, +# with grey warm bark, moss at the base and subtle broad plane variation. +vertices, faces, colors = [], [], [] + + +def tube(points, radii, sides=9): + start = len(vertices) + for index, raw in enumerate(points): + point = Vector(raw) + tangent = Vector(points[min(index + 1, len(points) - 1)]) - Vector(points[max(0, index - 1)]) + tangent.normalize() + side = tangent.cross(Vector((0, 1, 0))).normalized() + other = tangent.cross(side).normalized() + for segment in range(sides): + angle = math.tau * segment / sides + offset = (math.cos(angle) * side + math.sin(angle) * other) * radii[index] + vertices.append(point + offset) + shade = 0.90 + math.sin(angle * 3 + 0.6) * 0.12 + moss = max(0, 1 - point.z / 0.55) * (0.5 + 0.5 * math.cos(angle)) + colors.append(((0.20 - moss * 0.035) * shade, (0.13 + moss * 0.01) * shade, + (0.068 - moss * 0.005) * shade)) + if index: + for segment in range(sides): + a = start + (index - 1) * sides + segment + b = start + (index - 1) * sides + (segment + 1) % sides + faces.append((a, b, b + sides, a + sides)) + faces.append(tuple(reversed(range(start, start + sides)))) + faces.append(tuple(range(len(vertices) - sides, len(vertices)))) + + +tube([(0, 0, 0), (-0.07, 0.025, 0.22), (0.02, 0.0, 1.0), (-0.10, 0.025, 1.85), + (0.03, 0.02, 2.62), (0.22, -0.04, 3.48)], [0.38, 0.27, 0.23, 0.20, 0.14, 0.025]) +tube([(-0.06, 0, 1.70), (-0.36, 0.04, 2.35), (-0.84, 0.03, 2.83), (-1.05, 0.09, 3.23)], + [0.18, 0.13, 0.075, 0.012], 7) +tube([(0.00, 0, 2.10), (0.39, 0.12, 2.48), (0.81, 0.18, 2.94), (1.00, 0.25, 3.29)], + [0.14, 0.10, 0.055, 0.012], 7) +tube([(-0.06, 0.025, 2.34), (-0.22, -0.38, 2.83), (-0.35, -0.62, 3.26)], + [0.10, 0.058, 0.012], 7) +for index in range(5): + angle = index * math.tau / 5 + 0.2 + tube([(math.cos(angle) * 0.13, math.sin(angle) * 0.13, 0.30), + (math.cos(angle) * 0.36, math.sin(angle) * 0.36, 0.08), + (math.cos(angle + 0.12) * 0.66, math.sin(angle + 0.12) * 0.66, -0.015)], + [0.12, 0.085, 0.012], 6) +trunk = mesh_object("Trunk", vertices, faces, colors, material("BarkPigment")) + +# Export object origins at zero; runtime scales/repositions four shared crowns. +bpy.ops.object.select_all(action="SELECT") +bpy.ops.export_scene.gltf(filepath=str(OUTPUT / "painterly_tree.glb"), export_format="GLB", + use_selection=True, export_yup=True, export_animations=False, + export_vertex_color="ACTIVE", export_all_vertex_colors=False, + export_materials="EXPORT", export_extras=False, export_cameras=False) + +# Display a complete tree in the editable source without baking placement into GLB. +source_tint(canopy.data.materials[0], (0.22, 0.42, 0.10)) +canopy.location.z = 3.8 +canopy.scale = (1.8, 1.55, 1.4) +for location, scale in (((-0.85, 0.05, 3.55), (1.28, 1.22, 1.15)), + ((0.87, -0.08, 3.67), (1.27, 1.15, 1.1)), + ((0.05, 0, 4.46), (1.20, 1.08, 1.0))): + crown = canopy.copy() + crown.data = canopy.data + bpy.context.collection.objects.link(crown) + crown.location = location + crown.scale = scale +for screen in bpy.data.screens: + for area in screen.areas: + if area.type == "VIEW_3D": + area.spaces.active.shading.type = "MATERIAL" +bpy.context.preferences.filepaths.save_version = 0 +bpy.ops.wm.save_as_mainfile(filepath=str(SOURCE / "painterly_tree.blend")) + +budget = {} +for obj in (canopy, trunk): + obj.data.calc_loop_triangles() + budget[obj.name] = {"triangles": len(obj.data.loop_triangles), "surfaces": 1, + "vertices": len(obj.data.vertices)} +budget["runtime_tree"] = {"triangles": budget["Canopy"]["triangles"] * 4 + budget["Trunk"]["triangles"], + "surfaces": 5, "shared_meshes": 2} +budget["glb_bytes"] = (OUTPUT / "painterly_tree.glb").stat().st_size +(OUTPUT / "tree_mesh_budget.json").write_text(json.dumps(budget, indent=2) + "\n") +print("PAINTERLY TREE BUDGET", json.dumps(budget)) + +# Narrow evergreen boughs, scalloped skirts and an uneven leader. One opaque +# surface is joined across eight whorls, preserving a restrained draw budget. +bpy.ops.object.select_all(action="SELECT") +bpy.ops.object.delete(use_global=False) +vertices, faces, colors = [], [], [] +segments = 24 +for tier in range(8): + height = 0.64 + tier * 0.27 + radius = 0.62 * (1 - tier / 8.8) + start = len(vertices) + angle_offset = tier * 0.61 + lean_x = math.sin(tier * 0.8) * 0.035 + lean_y = math.cos(tier * 1.1) * 0.025 + for ring, (radial, lift) in enumerate(((0.78, 0.04), (1.0, 0.08), (0.63, 0.25), (0.12, 0.57))): + for segment in range(segments): + angle = math.tau * segment / segments + angle_offset + tip = (1 + math.cos(angle * 8 + tier)) * 0.5 + organic = math.sin(angle * 3 + tier) * 0.08 + math.sin(angle * 5 - tier * 2) * 0.035 + r = radius * radial * (0.84 + tip * 0.16 + organic) + z = height + lift - tip * (0.085 if ring < 2 else 0.022) + vertices.append((lean_x + math.cos(angle) * r, lean_y + math.sin(angle) * r, z)) + pigment = 0.72 + tier * 0.015 + ring * 0.05 + tip * 0.045 + colors.append((pigment * 0.94, pigment, pigment * 0.93)) + if ring: + a = start + (ring - 1) * segments + segment + b = start + (ring - 1) * segments + (segment + 1) % segments + faces.append((a, b, b + segments, a + segments)) + faces.append(tuple(reversed(range(start, start + segments)))) + faces.append(tuple(range(len(vertices) - segments, len(vertices)))) +conifer = mesh_object("ConiferCanopy", vertices, faces, colors, material("EvergreenPigment")) +vertices, faces, colors = [], [], [] +tube([(0, 0, 0), (0.015, -0.01, 0.48), (-0.02, 0, 1.5), (0, 0, 2.58), (0.01, 0, 2.84)], + [0.10, 0.07, 0.045, 0.02, 0.004], 8) +conifer_trunk = mesh_object("ConiferTrunk", vertices, faces, colors, material("EvergreenBark")) +bpy.ops.object.select_all(action="SELECT") +bpy.ops.export_scene.gltf(filepath=str(OUTPUT / "painterly_conifer.glb"), export_format="GLB", + use_selection=True, export_yup=True, export_animations=False, + export_vertex_color="ACTIVE", export_all_vertex_colors=False, + export_materials="EXPORT", export_extras=False, export_cameras=False) +source_tint(conifer.data.materials[0], (0.10, 0.28, 0.17)) +bpy.ops.wm.save_as_mainfile(filepath=str(SOURCE / "painterly_conifer.blend")) +for obj in (conifer, conifer_trunk): + obj.data.calc_loop_triangles() +budget["conifer"] = {"triangles": sum(len(o.data.loop_triangles) for o in (conifer, conifer_trunk)), + "surfaces": 2, "height_m": 3.10, + "glb_bytes": (OUTPUT / "painterly_conifer.glb").stat().st_size} +(OUTPUT / "tree_mesh_budget.json").write_text(json.dumps(budget, indent=2) + "\n") +print("CONIFER BUDGET", json.dumps(budget["conifer"])) diff --git a/tools/capture_painterly_environment.gd b/tools/capture_painterly_environment.gd new file mode 100644 index 0000000..7aafc38 --- /dev/null +++ b/tools/capture_painterly_environment.gd @@ -0,0 +1,127 @@ +extends SceneTree +## Main-scene review at fixed simulation time; never writes a save. + +const OUTPUT := "res://docs/baselines/" + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + root.size = Vector2i(1600, 900) + var label := "after" + for argument in OS.get_cmdline_user_args(): + if argument.begins_with("--label="): + label = argument.trim_prefix("--label=").validate_filename() + var main := load("res://main.tscn").instantiate() as Node3D + main.get_node("SimulationManager").set("debug_logs", false) + root.add_child(main) + var rig := main.get_node("CameraRig") + rig.set_process_unhandled_input(false) + Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE) + await process_frame + main.get_node("SimulationManager").set_process(false) + var cycle := main.get_node("JajceWorld/DayNightCycle") + cycle.set_process(false) + cycle.call("_apply_light_rotation", 0.4) + cycle.call("_apply_environment", 0.4) + var demo := main.get_node("DemoController") + if demo.get("debug_overlay_visible"): + demo.call("toggle_debug_overlay") + for child in main.get_children(): + if child is CanvasLayer: + child.hide() + for _frame in 45: + await process_frame + main.get_node("Player").set_physics_process(false) + rig.set_physics_process(false) + rig.call("apply_presentation_preset", main.get_node("Player").global_position, 0.0, 0.5) + await _save(label + "_gameplay") + var camera := root.get_camera_3d() + camera.reparent(main) + camera.global_position = Vector3(38, 24, 44) + camera.look_at(Vector3(-4, 3, 0)) + camera.fov = 53.0 + await _save(label + "_valley") + camera.global_position = Vector3(38, 24, 44) + camera.look_at(Vector3(-4, 12, 0)) + camera.fov = 58.0 + await _save(label + "_vista") + camera.global_position = Vector3(5, 4.8, 24) + camera.look_at(Vector3(-7, 2.8, 9)) + camera.fov = 55.0 + await _save(label + "_meadow") + cycle.call("_apply_light_rotation", 0.92) + cycle.call("_apply_environment", 0.92) + await _save(label + "_night") + cycle.call("_apply_light_rotation", 0.4) + cycle.call("_apply_environment", 0.4) + camera.global_position = Vector3(38, 24, 44) + camera.look_at(Vector3(-4, 3, 0)) + camera.fov = 53.0 + await _sample_profiles(main, label) + quit(0) + + +func _sample_profiles(main: Node3D, label: String) -> void: + var world := main.get_node("JajceWorld") as JajceWorld + var samples: Array[Dictionary] = [] + for quality in [0, 1, 2]: + world.apply_presentation_quality(quality) + for _frame in 45: + await process_frame + var frame_times: Array[float] = [] + for _frame in 120: + var start := Time.get_ticks_usec() + await process_frame + frame_times.append(float(Time.get_ticks_usec() - start) / 1000.0) + frame_times.sort() + var sample := { + "profile": world.get_active_presentation_quality_name(), + "render_scale": root.scaling_3d_scale, + "msaa_enum": root.msaa_3d, + "wall_frame_p50_ms": frame_times[60], + "wall_frame_p95_ms": frame_times[114], + "draw_calls": Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME), + "primitives": Performance.get_monitor(Performance.RENDER_TOTAL_PRIMITIVES_IN_FRAME), + "active_npcs": main.get_node("ActiveNPCs").get_child_count(), + } + if world.has_node("PainterlyMeadow"): + sample["meadow"] = world.get_node("PainterlyMeadow").get_presentation_stats() + samples.append(sample) + var file := FileAccess.open(OUTPUT + "painterly_" + label + "_metrics.json", FileAccess.WRITE) + assert(file != null) + ( + file + . store_string( + ( + ( + JSON + . stringify( + { + "renderer": RenderingServer.get_current_rendering_driver_name(), + "device": RenderingServer.get_video_adapter_name(), + "resolution": "1600x900", + "workload": + "Paused main scene, static valley camera; wall time includes vsync", + "samples": samples, + }, + "\t" + ) + ) + + "\n" + ) + ) + ) + + +func _save(label: String) -> void: + for _frame in 75: + await process_frame + await RenderingServer.frame_post_draw + var capture := root.get_texture().get_image() + assert(not capture.is_empty()) + var path := OUTPUT + "painterly_" + label + ".png" + assert(capture.save_png(path) == OK) + print("[TOOL] Saved " + path) diff --git a/tools/capture_painterly_environment.gd.uid b/tools/capture_painterly_environment.gd.uid new file mode 100644 index 0000000..4314e15 --- /dev/null +++ b/tools/capture_painterly_environment.gd.uid @@ -0,0 +1 @@ +uid://d136scmaukb0d diff --git a/world/jajce/JajceWorld.tscn b/world/jajce/JajceWorld.tscn index 3857bb8..3fc5469 100644 --- a/world/jajce/JajceWorld.tscn +++ b/world/jajce/JajceWorld.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=68 format=3] +[gd_scene load_steps=71 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"] @@ -21,8 +21,8 @@ [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/PainterlyConifer.tscn" id="22_conifer"] +[ext_resource type="PackedScene" path="res://world/jajce/PainterlyConifer.tscn" 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"] [ext_resource type="PackedScene" path="res://world/jajce/RiverbankResourceCluster.tscn" id="26_resource_cluster"] @@ -33,8 +33,12 @@ [ext_resource type="ArrayMesh" path="res://assets/storybook/meadow_grass.res" id="31_meadow"] [ext_resource type="Shader" path="res://world/jajce/materials/storybook_sky.gdshader" id="32_sky"] +[ext_resource type="Texture2D" path="res://assets/painterly/painted_sky.png" id="36_painted_sky"] [ext_resource type="PackedScene" path="res://world/jajce/StylizedBerryPatch.tscn" id="33_berry"] +[ext_resource type="Script" path="res://world/jajce/PainterlyMeadow.gd" id="34_meadow"] +[ext_resource type="PackedScene" path="res://world/jajce/HarvestableTreePresentation.tscn" id="35_harvest_tree"] + [sub_resource type="Terrain3DMaterial" id="Terrain3DMaterial_jajce"] _shader_parameters = { "auto_base_texture": 0, @@ -42,8 +46,8 @@ _shader_parameters = { "auto_slope": 0.62, "blend_sharpness": 0.88, "enable_macro_variation": true, -"macro_variation1": Color(0.84, 0.95, 0.73, 1), -"macro_variation2": Color(0.60, 0.79, 0.61, 1), +"macro_variation1": Color(0.91, 0.98, 0.80, 1), +"macro_variation2": Color(0.76, 0.89, 0.76, 1), "macro_variation_slope": 0.48 } world_background = 0 @@ -179,6 +183,7 @@ size = Vector3(2.9, 0.08, 1.4) [sub_resource type="ShaderMaterial" id="SkyMaterial_jajce"] resource_local_to_scene = true shader = ExtResource("32_sky") +shader_parameter/painted_panorama = ExtResource("36_painted_sky") [sub_resource type="Sky" id="Sky_jajce"] resource_local_to_scene = true @@ -188,17 +193,17 @@ sky_material = SubResource("SkyMaterial_jajce") resource_local_to_scene = true background_mode = 2 sky = SubResource("Sky_jajce") -background_energy_multiplier = 0.9 -ambient_light_source = 3 -ambient_light_color = Color(0.95, 0.9, 0.76, 1) -ambient_light_energy = 0.75 +background_energy_multiplier = 1.0 +ambient_light_source = 2 +ambient_light_color = Color(0.65, 0.8, 0.87, 1) +ambient_light_energy = 0.65 reflected_light_source = 2 tonemap_mode = 2 glow_enabled = true glow_normalized = true -glow_intensity = 0.18 +glow_intensity = 0.12 glow_strength = 0.9 -glow_bloom = 0.03 +glow_bloom = 0.015 fog_enabled = true fog_light_color = Color(0.88, 0.84, 0.7, 1) fog_light_energy = 0.78 @@ -216,8 +221,8 @@ volumetric_fog_length = 96.0 volumetric_fog_detail_spread = 1.6 adjustment_enabled = true adjustment_brightness = 1.0 -adjustment_contrast = 1.03 -adjustment_saturation = 1.04 +adjustment_contrast = 1.015 +adjustment_saturation = 1.07 [sub_resource type="StandardMaterial3D" id="Material_guard"] albedo_color = Color(0.55, 0.45, 0.35, 1) @@ -384,6 +389,9 @@ scale = Vector3(1.1, 1, 9) cast_shadow = 0 mesh = SubResource("Mesh_path_readability") +[node name="PainterlyMeadow" type="Node3D" parent="."] +script = ExtResource("34_meadow") + [node name="FoliageRoot" type="Node3D" parent="."] [node name="Tree_West_01" parent="FoliageRoot" instance=ExtResource("4_tree")] @@ -448,6 +456,91 @@ position = Vector3(8, 0, -31) rotation_degrees = Vector3(0, -20, 0) scale = Vector3(2.65, 2.65, 2.65) +[node name="Woodland_Edge_01" parent="FoliageRoot" instance=ExtResource("4_tree")] +position = Vector3(-34, 0, -29) +rotation_degrees = Vector3(0, 0, 0) +scale = Vector3(1.3, 1.3, 1.3) + +[node name="Woodland_Edge_02" parent="FoliageRoot" instance=ExtResource("4_tree")] +position = Vector3(-39, 0, -25) +rotation_degrees = Vector3(0, 137, 0) +scale = Vector3(1.6, 1.6, 1.6) + +[node name="Woodland_Edge_03" parent="FoliageRoot" instance=ExtResource("4_tree")] +position = Vector3(-44, 0, -18) +rotation_degrees = Vector3(0, 274, 0) +scale = Vector3(1.5, 1.5, 1.5) + +[node name="Woodland_Edge_04" parent="FoliageRoot" instance=ExtResource("4_tree")] +position = Vector3(-45, 0, -8) +rotation_degrees = Vector3(0, 51, 0) +scale = Vector3(1.8, 1.8, 1.8) + +[node name="Woodland_Edge_05" parent="FoliageRoot" instance=ExtResource("4_tree")] +position = Vector3(-43, 0, 4) +rotation_degrees = Vector3(0, 188, 0) +scale = Vector3(1.15, 1.15, 1.15) + +[node name="Woodland_Edge_06" parent="FoliageRoot" instance=ExtResource("4_tree")] +position = Vector3(-41, 0, 16) +rotation_degrees = Vector3(0, 325, 0) +scale = Vector3(1.3, 1.3, 1.3) + +[node name="Woodland_Edge_07" parent="FoliageRoot" instance=ExtResource("4_tree")] +position = Vector3(-36, 0, 28) +rotation_degrees = Vector3(0, 102, 0) +scale = Vector3(1.2, 1.2, 1.2) + +[node name="Woodland_Edge_08" parent="FoliageRoot" instance=ExtResource("4_tree")] +position = Vector3(-26, 0, -35) +rotation_degrees = Vector3(0, 239, 0) +scale = Vector3(1.2, 1.2, 1.2) + +[node name="Woodland_Edge_09" parent="FoliageRoot" instance=ExtResource("4_tree")] +position = Vector3(-19, 0, -37) +rotation_degrees = Vector3(0, 16, 0) +scale = Vector3(1.55, 1.55, 1.55) + +[node name="Woodland_Edge_10" parent="FoliageRoot" instance=ExtResource("4_tree")] +position = Vector3(-11, 0, -38) +rotation_degrees = Vector3(0, 153, 0) +scale = Vector3(1.35, 1.35, 1.35) + +[node name="Woodland_Edge_11" parent="FoliageRoot" instance=ExtResource("4_tree")] +position = Vector3(-3, 0, -36) +rotation_degrees = Vector3(0, 290, 0) +scale = Vector3(1.4, 1.4, 1.4) + +[node name="Woodland_Edge_12" parent="FoliageRoot" instance=ExtResource("4_tree")] +position = Vector3(5, 0, -39) +rotation_degrees = Vector3(0, 67, 0) +scale = Vector3(1.25, 1.25, 1.25) + +[node name="Woodland_Edge_13" parent="FoliageRoot" instance=ExtResource("4_tree")] +position = Vector3(13, 0, -35) +rotation_degrees = Vector3(0, 204, 0) +scale = Vector3(1.3, 1.3, 1.3) + +[node name="Woodland_Edge_14" parent="FoliageRoot" instance=ExtResource("4_tree")] +position = Vector3(43, 0, -14) +rotation_degrees = Vector3(0, 341, 0) +scale = Vector3(1.5, 1.5, 1.5) + +[node name="Woodland_Edge_15" parent="FoliageRoot" instance=ExtResource("4_tree")] +position = Vector3(46, 0, -5) +rotation_degrees = Vector3(0, 118, 0) +scale = Vector3(1.5, 1.5, 1.5) + +[node name="Woodland_Edge_16" parent="FoliageRoot" instance=ExtResource("4_tree")] +position = Vector3(43, 0, 4) +rotation_degrees = Vector3(0, 255, 0) +scale = Vector3(1.15, 1.15, 1.15) + +[node name="Woodland_Edge_17" parent="FoliageRoot" instance=ExtResource("4_tree")] +position = Vector3(43, 0, 14) +rotation_degrees = Vector3(0, 32, 0) +scale = Vector3(1.3, 1.3, 1.3) + [node name="WildlifeRoot" type="Node3D" parent="."] [node name="Butterfly_Meadow_01" parent="WildlifeRoot" instance=ExtResource("24_butterfly")] @@ -579,6 +672,12 @@ yield_per_action = 3.0 safety_risk = 0.12 comfort_distance = 24.0 +[node name="MeshInstance3D" parent="WorldObjects/ResourceNodes/Tree_01/Visual"] +visible = false + +[node name="TreePresentation" parent="WorldObjects/ResourceNodes/Tree_01/Visual" instance=ExtResource("35_harvest_tree")] +scale = Vector3(0.82, 0.82, 0.82) + [node name="Tree_02" parent="WorldObjects/ResourceNodes" instance=ExtResource("2_resource")] position = Vector3(10, 0, -4) node_id = &"tree_02" @@ -589,6 +688,12 @@ yield_per_action = 3.0 safety_risk = 0.16 comfort_distance = 26.0 +[node name="MeshInstance3D" parent="WorldObjects/ResourceNodes/Tree_02/Visual"] +visible = false + +[node name="TreePresentation" parent="WorldObjects/ResourceNodes/Tree_02/Visual" instance=ExtResource("35_harvest_tree")] +scale = Vector3(0.82, 0.82, 0.82) + [node name="Tree_North_Outskirts_01" parent="WorldObjects/ResourceNodes" instance=ExtResource("2_resource")] position = Vector3(6, 0, 12) node_id = &"tree_north_outskirts_01" @@ -600,6 +705,12 @@ safety_risk = 0.2 comfort_distance = 28.0 discovery_priority = 0.25 +[node name="MeshInstance3D" parent="WorldObjects/ResourceNodes/Tree_North_Outskirts_01/Visual"] +visible = false + +[node name="TreePresentation" parent="WorldObjects/ResourceNodes/Tree_North_Outskirts_01/Visual" instance=ExtResource("35_harvest_tree")] +scale = Vector3(0.82, 0.82, 0.82) + [node name="Tree_South_Edge_01" parent="WorldObjects/ResourceNodes" instance=ExtResource("2_resource")] position = Vector3(-12, 0, -12) node_id = &"tree_south_edge_01" @@ -610,6 +721,12 @@ yield_per_action = 2.0 safety_risk = 0.24 comfort_distance = 26.0 +[node name="MeshInstance3D" parent="WorldObjects/ResourceNodes/Tree_South_Edge_01/Visual"] +visible = false + +[node name="TreePresentation" parent="WorldObjects/ResourceNodes/Tree_South_Edge_01/Visual" instance=ExtResource("35_harvest_tree")] +scale = Vector3(0.82, 0.82, 0.82) + [node name="WoodPile_Village_01" parent="WorldObjects/ResourceNodes" instance=ExtResource("2_resource")] position = Vector3(5, 0, -13) node_id = &"wood_pile_village_01" @@ -914,7 +1031,8 @@ text = "Rest Bench" [node name="DirectionalLight3D" type="DirectionalLight3D" parent="."] rotation_degrees = Vector3(-55, -35, 0) light_color = Color(1, 0.87, 0.72, 1) -light_energy = 1.45 +light_energy = 1.35 +light_angular_distance = 0.6 shadow_enabled = true directional_shadow_max_distance = 180.0 diff --git a/world/jajce/PainterlyConifer.gd b/world/jajce/PainterlyConifer.gd new file mode 100644 index 0000000..6228963 --- /dev/null +++ b/world/jajce/PainterlyConifer.gd @@ -0,0 +1,24 @@ +extends Node3D +## Shared opaque boughs; decorative presentation without simulation state. + +const WIND_SHADER := preload("res://world/jajce/materials/wind.gdshader") + + +func _ready() -> void: + var canopy := $Geometry/ConiferCanopy as MeshInstance3D + var material := ShaderMaterial.new() + material.shader = WIND_SHADER + material.set_shader_parameter("albedo", Color(0.28, 0.47, 0.33)) + material.set_shader_parameter("painted_vertex_weight", 1.0) + material.set_shader_parameter("wind_strength", 0.055) + material.set_shader_parameter("wind_speed", 0.55) + material.set_shader_parameter("wind_phase", float(posmod(hash(global_position), 6283)) / 1000.0) + material.set_shader_parameter("wind_base_height", 0.45) + material.set_shader_parameter("wind_full_height", 3.1) + canopy.material_override = material + var trunk := $Geometry/ConiferTrunk as MeshInstance3D + var bark := StandardMaterial3D.new() + bark.vertex_color_use_as_albedo = true + bark.roughness = 0.95 + bark.specular_mode = BaseMaterial3D.SPECULAR_DISABLED + trunk.material_override = bark diff --git a/world/jajce/PainterlyConifer.gd.uid b/world/jajce/PainterlyConifer.gd.uid new file mode 100644 index 0000000..9ba71db --- /dev/null +++ b/world/jajce/PainterlyConifer.gd.uid @@ -0,0 +1 @@ +uid://bk3cxfe3kph2x diff --git a/world/jajce/PainterlyConifer.tscn b/world/jajce/PainterlyConifer.tscn new file mode 100644 index 0000000..eeeb73f --- /dev/null +++ b/world/jajce/PainterlyConifer.tscn @@ -0,0 +1,9 @@ +[gd_scene load_steps=3 format=3] + +[ext_resource type="Script" path="res://world/jajce/PainterlyConifer.gd" id="1_script"] +[ext_resource type="PackedScene" path="res://assets/painterly/painterly_conifer.glb" id="2_geometry"] + +[node name="PainterlyConifer" type="Node3D"] +script = ExtResource("1_script") + +[node name="Geometry" parent="." instance=ExtResource("2_geometry")] diff --git a/world/jajce/PainterlyMeadow.gd b/world/jajce/PainterlyMeadow.gd new file mode 100644 index 0000000..af875ce --- /dev/null +++ b/world/jajce/PainterlyMeadow.gd @@ -0,0 +1,217 @@ +class_name PainterlyMeadow +extends Node3D +## Decorative flower drifts. Local seeded placement never touches simulation RNG. + +const FLOWER_SCENES: Array[PackedScene] = [ + preload("res://assets/meadow/cream_daisy.glb"), + preload("res://assets/meadow/pink_cosmos.glb"), + preload("res://assets/meadow/yellow_buttercup.glb"), + preload("res://assets/meadow/lavender_spire.glb"), +] +const FLOWER_SHADER := preload("res://world/jajce/materials/meadow_flowers.gdshader") +const PROFILE_DENSITY := [1.0, 0.65, 0.32] +const PROFILE_DISTANCE := [56.0, 46.0, 34.0] + +@export var terrain_path := NodePath("../TerrainRoot/Terrain3D") +@export_range(0, 450, 1) var clumps_per_species := 420 +@export var placement_seed := 57091 +## Local x/z center, then ellipse x/z radii. Each drift favors one flower color. +@export var drifts: Array[Vector4] = [ + Vector4(7.0, 20.0, 7.0, 3.5), + Vector4(15.0, 13.0, 2.4, 4.0), + Vector4(-5.0, 20.0, 5.0, 3.0), + Vector4(-18.0, -21.0, 4.5, 3.2), + Vector4(14.0, -10.0, 3.8, 4.5), + Vector4(-25.0, -17.0, 3.5, 4.0), + Vector4(8.0, -20.0, 5.0, 2.5), + Vector4(-2.0, -24.0, 6.0, 2.2), + Vector4(17.0, 24.0, 4.0, 3.0), + Vector4(-9.0, 21.0, 3.6, 2.5), + Vector4(9.0, 7.2, 3.2, 1.8), + Vector4(-16.0, 19.0, 3.0, 2.4), +] + +var _terrain: Terrain3D +var _batches: Array[MultiMeshInstance3D] = [] +var _material: ShaderMaterial +var _quality := 0 +var _clearings: Array[Vector3] = [] +var _path_strips: Array[MeshInstance3D] = [] + + +func _ready() -> void: + call_deferred("_build_meadow") + + +func apply_presentation_quality(quality: int) -> void: + _quality = clampi(quality, 0, PROFILE_DENSITY.size() - 1) + for batch in _batches: + batch.multimesh.visible_instance_count = floori( + batch.multimesh.instance_count * PROFILE_DENSITY[_quality] + ) + if _material != null: + _material.set_shader_parameter("fade_begin", PROFILE_DISTANCE[_quality] - 12.0) + _material.set_shader_parameter("fade_end", PROFILE_DISTANCE[_quality]) + + +func get_presentation_stats() -> Dictionary: + var total := 0 + var visible_count := 0 + for batch in _batches: + total += batch.multimesh.instance_count + visible_count += batch.multimesh.visible_instance_count + return { + "flower_clump_count": total, + "visible_flower_clump_count": visible_count, + "multimesh_batch_count": _batches.size(), + } + + +func _build_meadow() -> void: + if not _batches.is_empty(): + return + _terrain = get_node_or_null(terrain_path) as Terrain3D + if _terrain == null or _terrain.data == null or drifts.is_empty(): + return + _collect_clearings() + _material = ShaderMaterial.new() + _material.shader = FLOWER_SHADER + for species in FLOWER_SCENES.size(): + var source := FLOWER_SCENES[species].instantiate() + var mesh := _find_mesh(source) + if mesh != null: + _add_batch(mesh, species) + source.free() + apply_presentation_quality(_quality) + + +func _find_mesh(node: Node) -> Mesh: + if node is MeshInstance3D: + return (node as MeshInstance3D).mesh + for child in node.get_children(): + var mesh := _find_mesh(child) + if mesh != null: + return mesh + return null + + +func _add_batch(mesh: Mesh, species: int) -> void: + var rng := RandomNumberGenerator.new() + rng.seed = placement_seed + species * 173 + var transforms: Array[Transform3D] = [] + var variations: Array[Color] = [] + for _attempt in clumps_per_species * 18: + if transforms.size() >= clumps_per_species: + break + var drift_index := rng.randi_range(0, drifts.size() - 1) + # Broad color masses with a few companion flowers at the edges. + if drift_index % FLOWER_SCENES.size() != species and rng.randf() > 0.12: + continue + var drift := drifts[drift_index] + var angle := rng.randf_range(0.0, TAU) + var radius := pow(rng.randf(), 0.7) * (0.9 + 0.12 * sin(angle * 3.0 + drift_index)) + var offset := Vector3( + drift.x + cos(angle) * radius * drift.z, 0.0, drift.y + sin(angle) * radius * drift.w + ) + var world_sample := to_global(offset) + if _is_cleared(world_sample): + continue + var height := _terrain.data.get_height(world_sample) + if is_nan(height): + continue + var nearby := _terrain.data.get_height(world_sample + Vector3(0.35, 0.0, 0.35)) + if is_nan(nearby) or absf(height - nearby) > 0.32: + continue + var local_position := to_local(Vector3(world_sample.x, height - 0.018, world_sample.z)) + var scale_variation := rng.randf_range(0.80, 1.28) + var basis := Basis(Vector3.UP, rng.randf_range(0.0, TAU)).scaled( + Vector3.ONE * scale_variation + ) + transforms.append(Transform3D(basis, local_position)) + variations.append(Color(rng.randf(), 0.0, 0.0, 1.0)) + var multimesh := MultiMesh.new() + multimesh.transform_format = MultiMesh.TRANSFORM_3D + # Compatibility needs an explicit neutral color alongside the custom buffer + # to preserve the GLB's painted vertex colors. + multimesh.use_colors = true + multimesh.use_custom_data = true + multimesh.mesh = mesh + multimesh.instance_count = transforms.size() + for index in transforms.size(): + multimesh.set_instance_transform(index, transforms[index]) + multimesh.set_instance_color(index, Color.WHITE) + multimesh.set_instance_custom_data(index, variations[index]) + var batch := MultiMeshInstance3D.new() + batch.name = "FlowerDrifts_%d" % species + batch.multimesh = multimesh + batch.material_override = _material + batch.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + batch.extra_cull_margin = 0.4 + add_child(batch) + _batches.append(batch) + + +func _collect_clearings() -> void: + var village := get_node_or_null("../VillageRoot") + if village != null: + for child in village.get_children(): + if child is MeshInstance3D and str(child.name).begins_with("Path_"): + _path_strips.append(child) + elif child is Node3D: + var radius := 4.3 if str(child.name).begins_with("House_") else 4.5 + _add_clearing(child, radius) + var fortress := get_node_or_null("../FortressBlockout") as Node3D + if fortress != null: + _add_clearing(fortress, 11.0) + for root_path in [ + "../WorldObjects/ResourceNodes", + "../WorldObjects/StorageSites", + "../WorldObjects/ActivitySites", + "../WorldObjects/AnimalHabitats", + ]: + var root := get_node_or_null(root_path) + if root != null: + for child in root.get_children(): + if child is Node3D: + _add_worksite_clearing(child) + var clusters := get_node_or_null("../WorldObjects/ResourceClusters") + if clusters != null: + for cluster in clusters.get_children(): + var anchors := cluster.get_node_or_null("ResourceAnchors") + if anchors == null: + continue + for anchor in anchors.get_children(): + if anchor is Node3D: + _add_worksite_clearing(anchor) + + +func _add_worksite_clearing(node: Node3D) -> void: + _add_clearing(node, 2.0) + var interaction := node.get_node_or_null("InteractionPoint") as Node3D + if interaction != null: + _add_clearing(interaction, 1.2) + + +func _add_clearing(node: Node3D, radius: float) -> void: + _clearings.append(Vector3(node.global_position.x, node.global_position.z, radius)) + + +func _is_cleared(point: Vector3) -> bool: + var flat := Vector2(point.x, point.z) + for clearing in _clearings: + if flat.distance_squared_to(Vector2(clearing.x, clearing.y)) < clearing.z * clearing.z: + return true + for strip in _path_strips: + var box := strip.mesh as BoxMesh + if box == null: + continue + var local := strip.to_local(point) + var half_width := box.size.x * 0.5 + 0.35 / strip.global_basis.x.length() + if absf(local.x) < half_width and absf(local.z) < box.size.z * 0.5 + 0.08: + return true + var river_distance := absf(point.x - JajceWatercourse.downstream_center_x(point.z)) + return ( + point.z >= JajceWatercourse.DOWNSTREAM_START_Z - 3.0 + and point.z <= JajceWatercourse.DOWNSTREAM_END_Z + and river_distance < JajceWatercourse.downstream_half_width(point.z) + 1.7 + ) diff --git a/world/jajce/PainterlyMeadow.gd.uid b/world/jajce/PainterlyMeadow.gd.uid new file mode 100644 index 0000000..fb517c8 --- /dev/null +++ b/world/jajce/PainterlyMeadow.gd.uid @@ -0,0 +1 @@ +uid://cacjl430m8mcw diff --git a/world/jajce/RiverbankResourceCluster.tscn b/world/jajce/RiverbankResourceCluster.tscn index dcad6b5..ba4a7e4 100644 --- a/world/jajce/RiverbankResourceCluster.tscn +++ b/world/jajce/RiverbankResourceCluster.tscn @@ -4,7 +4,7 @@ [ext_resource type="PackedScene" path="res://world/resource_nodes/ResourceNode.tscn" id="2_resource"] [ext_resource type="PackedScene" path="res://world/jajce/StylizedTree.tscn" id="3_tree"] [ext_resource type="PackedScene" path="res://world/jajce/StylizedBerryPatch.tscn" id="4_berry"] -[ext_resource type="PackedScene" path="res://assets/foliage/tree.glb" id="5_conifer"] +[ext_resource type="PackedScene" path="res://world/jajce/PainterlyConifer.tscn" id="5_conifer"] [ext_resource type="PackedScene" path="res://world/jajce/HarvestableTreePresentation.tscn" id="6_harvestable_tree"] [node name="RiverbankResourceCluster" type="Node3D"] diff --git a/world/jajce/StylizedTree.gd b/world/jajce/StylizedTree.gd index b45f989..2a1e73a 100644 --- a/world/jajce/StylizedTree.gd +++ b/world/jajce/StylizedTree.gd @@ -7,15 +7,20 @@ enum CanopyVariant { } const COLORS := { - CanopyVariant.GREEN: Color(0.32, 0.51, 0.29), - CanopyVariant.DARK_GREEN: Color(0.22, 0.41, 0.30), - CanopyVariant.YELLOW_GREEN: Color(0.49, 0.60, 0.28), + CanopyVariant.GREEN: Color(0.40, 0.62, 0.28), + CanopyVariant.DARK_GREEN: Color(0.26, 0.51, 0.34), + CanopyVariant.YELLOW_GREEN: Color(0.55, 0.67, 0.29), } +const TREE_MODEL := preload("res://assets/painterly/painterly_tree.glb") const WIND_SHADER := preload("res://world/jajce/materials/wind.gdshader") const WIND_STRENGTH := 0.07 const WIND_SPEED := 0.55 +static var _canopy_mesh: Mesh +static var _trunk_mesh: Mesh +static var _bark_material: StandardMaterial3D + func _ready() -> void: for child in get_children(): @@ -28,76 +33,56 @@ func _ready() -> void: var canopy_radius := 1.25 + rng.randf() * 0.6 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 = 3.4 - trunk_mesh.radial_segments = 9 - - var trunk_mat := StandardMaterial3D.new() - trunk_mat.albedo_color = Color(0.27, 0.16, 0.09) - trunk_mat.roughness = 0.95 - trunk_mesh.material = trunk_mat - + _ensure_shared_meshes() var trunk := MeshInstance3D.new() trunk.name = "Trunk" - trunk.mesh = trunk_mesh - trunk.position = Vector3(0, 1.7, 0) + trunk.mesh = _trunk_mesh + trunk.material_override = _bark_material add_child(trunk) - _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_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), + Vector3(canopy_radius * 1.18, canopy_radius * 0.96, canopy_radius * 1.05), 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), + Vector3(-0.94, crown_height - 0.27, 0.14), + Vector3(canopy_radius * 0.84, canopy_radius * 0.79, canopy_radius * 0.84), 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), + Vector3(0.91, crown_height - 0.08, -0.2), + Vector3(canopy_radius * 0.82, canopy_radius * 0.73, canopy_radius * 0.79), 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), + Vector3(0.13, crown_height + 0.68, -0.08), + Vector3(canopy_radius * 0.78, canopy_radius * 0.74, canopy_radius * 0.75), 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) +static func _ensure_shared_meshes() -> void: + if _canopy_mesh != null: + return + var source := TREE_MODEL.instantiate() + _canopy_mesh = (source.find_child("Canopy", true, false) as MeshInstance3D).mesh + _trunk_mesh = (source.find_child("Trunk", true, false) as MeshInstance3D).mesh + _bark_material = StandardMaterial3D.new() + _bark_material.vertex_color_use_as_albedo = true + _bark_material.roughness = 0.95 + _bark_material.specular_mode = BaseMaterial3D.SPECULAR_DISABLED + source.free() func _add_canopy( @@ -107,11 +92,6 @@ func _add_canopy( 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) @@ -119,10 +99,12 @@ func _add_canopy( 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 + material.set_shader_parameter("painted_vertex_weight", 1.0) var canopy := MeshInstance3D.new() canopy.name = canopy_name - canopy.mesh = mesh + canopy.mesh = _canopy_mesh + canopy.material_override = material canopy.position = canopy_position canopy.scale = canopy_scale + canopy.rotation.y = wind_phase * 0.43 + canopy_position.x * 0.31 add_child(canopy) diff --git a/world/jajce/TerrainRiverRibbon.gd b/world/jajce/TerrainRiverRibbon.gd index 54ed4ba..9a3db82 100644 --- a/world/jajce/TerrainRiverRibbon.gd +++ b/world/jajce/TerrainRiverRibbon.gd @@ -48,6 +48,9 @@ func _build_ribbon( var uvs := PackedVector2Array() var indices := PackedInt32Array() var row_size := width_segments + 1 + var pool := $PlungePool as MeshInstance3D + var pool_mesh := pool.mesh as PlaneMesh + var pool_radii := pool_mesh.size * Vector2(pool.scale.x, pool.scale.z) * 0.5 for z_index in segment_count + 1: var progress := float(z_index) / float(segment_count) var world_z := lerpf(start_z, end_z, progress) @@ -61,16 +64,30 @@ func _build_ribbon( if is_upper else Watercourse.downstream_half_width(world_z) ) + if not is_upper: + # The outlet continues the existing pool silhouette without drawing a + # second, coplanar sheet on top of the pool's transparent material. + var pool_progress := (world_z - Watercourse.PLUNGE_POOL_CENTER.y) / pool_radii.y + if absf(pool_progress) < 1.0: + var pool_width := pool_radii.x * sqrt(1.0 - pool_progress * pool_progress) + half_width = maxf(half_width, pool_width) + # A stream has one surface across its width. Sampling each bank separately + # wraps the water mesh up the terrain and makes it read as a solid ramp. + var terrain_height := terrain.data.get_height(Vector3(center_x, 0.0, world_z)) + if is_nan(terrain_height): + terrain_height = global_position.y + var surface_height := terrain_height + water_offset + var left_x := _shoreline_x( + terrain, center_x, center_x - half_width, world_z, surface_height + ) + var right_x := _shoreline_x( + terrain, center_x, center_x + half_width, world_z, surface_height + ) for x_index in width_segments + 1: var across := float(x_index) / float(width_segments) - var world_x := center_x + lerpf(-half_width, half_width, across) + var world_x := lerpf(left_x, right_x, across) var world_sample := Vector3(world_x, 0.0, world_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)) - ) + vertices.append(to_local(Vector3(world_sample.x, surface_height, world_sample.z))) normals.append(Vector3.UP) uvs.append(Vector2(across, progress)) @@ -96,8 +113,37 @@ func _build_ribbon( return mesh +func _shoreline_x( + terrain: Terrain3D, center_x: float, edge_x: float, world_z: float, surface_height: float +) -> float: + var edge_height := terrain.data.get_height(Vector3(edge_x, 0.0, world_z)) + if is_nan(edge_height) or edge_height <= surface_height: + return edge_x + # The carved basin is wider on one side. Stop its visible surface at the + # shore instead of extending the pool's ideal ellipse metres under a hill. + var wet_x := center_x + var dry_x := edge_x + for step in range(1, 17): + var sample_x := lerpf(center_x, edge_x, float(step) / 16.0) + var sample_height := terrain.data.get_height(Vector3(sample_x, 0.0, world_z)) + if is_nan(sample_height) or sample_height > surface_height: + dry_x = sample_x + break + wet_x = sample_x + for _step in 10: + var sample_x := (wet_x + dry_x) * 0.5 + var sample_height := terrain.data.get_height(Vector3(sample_x, 0.0, world_z)) + if is_nan(sample_height) or sample_height > surface_height: + dry_x = sample_x + else: + wet_x = sample_x + return wet_x + + func _align_pool_to_terrain(terrain: Terrain3D) -> void: var pool := $PlungePool as MeshInstance3D + var pool_material := pool.mesh.surface_get_material(0) as ShaderMaterial + pool_material.set_shader_parameter("pool_outlet_z", Watercourse.DOWNSTREAM_START_Z) var pool_sample := Vector3( Watercourse.PLUNGE_POOL_CENTER.x, 0.0, Watercourse.PLUNGE_POOL_CENTER.y ) diff --git a/world/jajce/WaterBody.tscn b/world/jajce/WaterBody.tscn index 9087864..60b389a 100644 --- a/world/jajce/WaterBody.tscn +++ b/world/jajce/WaterBody.tscn @@ -1,34 +1,32 @@ -[gd_scene load_steps=8 format=3] +[gd_scene load_steps=11 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"] +[ext_resource type="Shader" path="res://world/jajce/materials/water_foam.gdshader" id="3_foam_shader"] [sub_resource type="ShaderMaterial" id="ShaderMaterial_water"] shader = ExtResource("1_water_shader") -[sub_resource type="CylinderMesh" id="Mesh_plunge_pool"] -material = SubResource("ShaderMaterial_water") -top_radius = 7.6 -bottom_radius = 7.6 -height = 0.08 -radial_segments = 32 -rings = 2 +[sub_resource type="ShaderMaterial" id="ShaderMaterial_pool"] +shader = ExtResource("1_water_shader") +shader_parameter/pool_surface = true -[sub_resource type="StandardMaterial3D" id="Material_foam"] -transparency = 1 -shading_mode = 0 -albedo_color = Color(0.8, 0.93, 0.86, 0.22) -emission_enabled = true -emission = Color(0.34, 0.56, 0.48, 1) -emission_energy_multiplier = 0.06 -roughness = 0.74 +[sub_resource type="PlaneMesh" id="Mesh_plunge_pool"] +material = SubResource("ShaderMaterial_pool") +size = Vector2(15.2, 15.2) +subdivide_width = 20 +subdivide_depth = 20 -[sub_resource type="TorusMesh" id="Mesh_foam_ring"] -material = SubResource("Material_foam") -inner_radius = 5.15 -outer_radius = 5.5 -rings = 28 -ring_segments = 10 +[sub_resource type="ShaderMaterial" id="Material_foam"] +shader = ExtResource("3_foam_shader") + +[sub_resource type="ShaderMaterial" id="Material_pool_foam"] +shader = ExtResource("3_foam_shader") +shader_parameter/circular_wash = true + +[sub_resource type="PlaneMesh" id="Mesh_foam_ring"] +material = SubResource("Material_pool_foam") +size = Vector2(11, 11) [sub_resource type="PlaneMesh" id="Mesh_foam_patch"] material = SubResource("Material_foam") @@ -49,10 +47,11 @@ cast_shadow = 0 [node name="PlungePool" type="MeshInstance3D" parent="."] scale = Vector3(1.25, 1, 0.82) +cast_shadow = 0 mesh = SubResource("Mesh_plunge_pool") [node name="FoamRing" type="MeshInstance3D" parent="."] -scale = Vector3(1.25, 0.12, 0.82) +scale = Vector3(1.25, 1, 0.82) cast_shadow = 0 mesh = SubResource("Mesh_foam_ring") diff --git a/world/jajce/WaterfallBody.tscn b/world/jajce/WaterfallBody.tscn index 7b5d65d..dd2f099 100644 --- a/world/jajce/WaterfallBody.tscn +++ b/world/jajce/WaterfallBody.tscn @@ -1,6 +1,7 @@ -[gd_scene load_steps=12 format=3] +[gd_scene load_steps=13 format=3] [ext_resource type="Shader" path="res://world/jajce/materials/waterfall.gdshader" id="1_waterfall_shader"] +[ext_resource type="Shader" path="res://world/jajce/materials/water_foam.gdshader" id="2_foam_shader"] [sub_resource type="ShaderMaterial" id="ShaderMaterial_waterfall"] shader = ExtResource("1_waterfall_shader") @@ -34,14 +35,8 @@ size = Vector3(4.8, 5.5, 3.4) material = SubResource("Material_rock_moss") size = Vector3(10.4, 0.65, 3.0) -[sub_resource type="StandardMaterial3D" id="Material_lip_foam"] -transparency = 1 -shading_mode = 0 -albedo_color = Color(0.82, 0.95, 0.87, 0.72) -emission_enabled = true -emission = Color(0.46, 0.72, 0.62, 1) -emission_energy_multiplier = 0.22 -roughness = 0.62 +[sub_resource type="ShaderMaterial" id="Material_lip_foam"] +shader = ExtResource("2_foam_shader") [sub_resource type="PlaneMesh" id="Mesh_lip_foam"] material = SubResource("Material_lip_foam") diff --git a/world/jajce/day_night_cycle.gd b/world/jajce/day_night_cycle.gd index 0b33f9d..1532d5a 100644 --- a/world/jajce/day_night_cycle.gd +++ b/world/jajce/day_night_cycle.gd @@ -7,17 +7,17 @@ extends Node const TO_RAD := PI / 180.0 -const DAY_SKY_TOP := Color(0.27, 0.57, 0.74, 1) -const DAY_SKY_HORIZON := Color(0.79, 0.88, 0.82, 1) +const DAY_SKY_TOP := Color(0.12, 0.49, 0.69, 1) +const DAY_SKY_HORIZON := Color(0.65, 0.84, 0.84, 1) const DAY_GROUND_BOTTOM := Color(0.12, 0.17, 0.12, 1) -const DAY_GROUND_HORIZON := Color(0.56, 0.69, 0.57, 1) -const DAY_AMBIENT := Color(0.72, 0.83, 0.88, 1) -const DAY_AMBIENT_ENERGY := 0.55 -const DAY_FOG_LIGHT := Color(0.70, 0.83, 0.81, 1) +const DAY_GROUND_HORIZON := DAY_SKY_HORIZON +const DAY_AMBIENT := Color(0.65, 0.80, 0.87, 1) +const DAY_AMBIENT_ENERGY := 0.65 +const DAY_FOG_LIGHT := Color(0.63, 0.80, 0.82, 1) const DAY_FOG_LIGHT_ENERGY := 0.8 -const DAY_FOG_DENSITY := 0.0015 +const DAY_FOG_DENSITY := 0.0018 const DAY_LIGHT_COLOR := Color(1, 0.94, 0.79, 1) -const DAY_LIGHT_ENERGY := 1.2 +const DAY_LIGHT_ENERGY := 1.35 const NIGHT_SKY_TOP := Color(0.04, 0.04, 0.12, 1) const NIGHT_SKY_HORIZON := Color(0.08, 0.06, 0.14, 1) @@ -76,8 +76,13 @@ func _process(delta: float) -> void: func _apply_light_rotation(cycle: float) -> void: if directional_light == null: return - var angle_rad: float = cycle * 2.0 * PI - directional_light.rotation = Vector3(-PI / 4.0 + sin(angle_rad) * PI / 4.0, angle_rad, 0) + # The bright interval and the sun's arc share dawn/noon/dusk. The old + # quarter-cycle offset put the brightest morning sun on the horizon. + var solar_progress := clampf((cycle - 0.15) / 0.7, 0.0, 1.0) + var elevation := 8.0 + sin(solar_progress * PI) * 52.0 + if cycle < 0.15 or cycle > 0.85: + elevation = 32.0 # One cool moon fill during the night interval. + directional_light.rotation = Vector3(-elevation * TO_RAD, cycle * TAU, 0) func _apply_environment(cycle: float) -> void: @@ -85,7 +90,6 @@ func _apply_environment(cycle: float) -> void: return var day_factor := _day_factor(cycle) - var night_factor := 1.0 - day_factor directional_light.light_color = _lerp_color(NIGHT_LIGHT_COLOR, DAY_LIGHT_COLOR, day_factor) directional_light.light_energy = lerpf(NIGHT_LIGHT_ENERGY, DAY_LIGHT_ENERGY, day_factor) @@ -136,6 +140,7 @@ func _apply_environment(cycle: float) -> void: "ground_horizon_color", NIGHT_GROUND_HORIZON.lerp(DAY_GROUND_HORIZON, day_factor) ) mat.set_shader_parameter("daylight", day_factor) + mat.set_shader_parameter("golden_hour", transition_peak) func _find_simulation_node() -> void: @@ -155,10 +160,10 @@ func _day_factor(cycle: float) -> float: func _sunrise_sunset_weight(cycle: float) -> float: - if cycle < 0.15: - return smoothstep(0.05, 0.15, cycle) * (1.0 - smoothstep(0.1, 0.15, cycle)) - if cycle > 0.85: - return smoothstep(0.85, 0.95, cycle) * (1.0 - smoothstep(0.9, 0.95, cycle)) + if cycle < 0.3: + return smoothstep(0.15, 0.21, cycle) * (1.0 - smoothstep(0.23, 0.3, cycle)) + if cycle > 0.7: + return smoothstep(0.7, 0.77, cycle) * (1.0 - smoothstep(0.79, 0.85, cycle)) return 0.0 diff --git a/world/jajce/jajce_world.gd b/world/jajce/jajce_world.gd index 42155a3..689f41b 100644 --- a/world/jajce/jajce_world.gd +++ b/world/jajce/jajce_world.gd @@ -32,6 +32,7 @@ static var _viewport_scale_states: Dictionary = {} var _active_presentation_quality := -1 var _authored_presentation_captured := false var _authored_render_scale := 1.0 +var _authored_msaa: int = Viewport.MSAA_DISABLED var _authored_glow_enabled := false var _authored_volumetric_fog_enabled := HIGH_VOLUMETRIC_FOG_ENABLED var _authored_adjustment_enabled := false @@ -45,6 +46,7 @@ var _authored_grass_interactors := 1 var _authored_grass_update_interval := 0.1 var _authored_grass_controller_enabled := true var _last_applied_render_scale := -1.0 +var _last_applied_msaa := -1 var _viewport_instance_id := 0 @@ -78,7 +80,8 @@ func apply_presentation_quality(quality: int) -> bool: _authored_volumetric_fog_enabled, _authored_adjustment_enabled, _authored_shadow_distance, - _authored_shadow_mode + _authored_shadow_mode, + _authored_msaa ) _apply_grass_quality( _authored_grass_visible, @@ -96,7 +99,8 @@ func apply_presentation_quality(quality: int) -> bool: false, _authored_adjustment_enabled, minf(_authored_shadow_distance, BALANCED_SHADOW_DISTANCE), - DirectionalLight3D.SHADOW_PARALLEL_2_SPLITS + DirectionalLight3D.SHADOW_PARALLEL_2_SPLITS, + mini(_authored_msaa, Viewport.MSAA_2X) ) _apply_grass_quality( _authored_grass_visible, @@ -114,7 +118,8 @@ func apply_presentation_quality(quality: int) -> bool: false, false, minf(_authored_shadow_distance, LOW_SHADOW_DISTANCE), - DirectionalLight3D.SHADOW_ORTHOGONAL + DirectionalLight3D.SHADOW_ORTHOGONAL, + Viewport.MSAA_DISABLED ) _apply_grass_quality( false, @@ -126,6 +131,9 @@ func apply_presentation_quality(quality: int) -> bool: false ) + var meadow := get_node_or_null("PainterlyMeadow") + if meadow != null: + meadow.call("apply_presentation_quality", quality) _active_presentation_quality = quality return true @@ -223,11 +231,14 @@ func _apply_environment_quality( volumetric_fog_enabled: bool, adjustment_enabled: bool, shadow_distance: float, - shadow_mode: int + shadow_mode: int, + msaa: int ) -> void: _claim_viewport_scale_owner() get_viewport().scaling_3d_scale = render_scale + get_viewport().msaa_3d = msaa _last_applied_render_scale = render_scale + _last_applied_msaa = msaa var environment := _environment() if environment != null: environment.glow_enabled = glow_enabled @@ -285,8 +296,13 @@ func _register_viewport_scale_owner() -> void: _viewport_instance_id = viewport.get_instance_id() var state: Dictionary = _viewport_scale_states.get(_viewport_instance_id, {}) if state.is_empty(): - state = {"base_scale": viewport.scaling_3d_scale, "owners": []} + state = { + "base_scale": viewport.scaling_3d_scale, + "base_msaa": viewport.msaa_3d, + "owners": [], + } _authored_render_scale = float(state["base_scale"]) + _authored_msaa = int(state["base_msaa"]) _viewport_scale_states[_viewport_instance_id] = state _claim_viewport_scale_owner() @@ -324,13 +340,20 @@ func _release_viewport_scale_owner() -> void: if not owners.is_empty(): state["owners"] = owners _viewport_scale_states[_viewport_instance_id] = state - if ( - was_active_owner - and is_equal_approx(viewport.scaling_3d_scale, _last_applied_render_scale) - ): + if was_active_owner: var previous_owner := (owners[-1] as WeakRef).get_ref() as JajceWorld - if previous_owner != null and previous_owner._last_applied_render_scale >= 0.0: + if ( + previous_owner != null + and previous_owner._last_applied_render_scale >= 0.0 + and is_equal_approx(viewport.scaling_3d_scale, _last_applied_render_scale) + ): viewport.scaling_3d_scale = previous_owner._last_applied_render_scale + if ( + previous_owner != null + and previous_owner._last_applied_msaa >= 0 + and viewport.msaa_3d == _last_applied_msaa + ): + viewport.msaa_3d = previous_owner._last_applied_msaa else: _viewport_scale_states.erase(_viewport_instance_id) if ( @@ -338,8 +361,11 @@ func _release_viewport_scale_owner() -> void: and is_equal_approx(viewport.scaling_3d_scale, _last_applied_render_scale) ): viewport.scaling_3d_scale = float(state["base_scale"]) + if was_active_owner and viewport.msaa_3d == _last_applied_msaa: + viewport.msaa_3d = int(state["base_msaa"]) _viewport_instance_id = 0 _last_applied_render_scale = -1.0 + _last_applied_msaa = -1 func _initialize_world_presentation() -> void: diff --git a/world/jajce/materials/meadow_flowers.gdshader b/world/jajce/materials/meadow_flowers.gdshader new file mode 100644 index 0000000..25f5205 --- /dev/null +++ b/world/jajce/materials/meadow_flowers.gdshader @@ -0,0 +1,36 @@ +// Original opaque petal geometry: soft wrap lighting without alpha overdraw. +shader_type spatial; +render_mode skip_vertex_transform, cull_disabled, specular_disabled; + +uniform vec2 wind_direction = vec2(1.0, 0.7); +uniform float wind_amount : hint_range(0.0, 1.0) = 0.115; +uniform float fade_begin = 42.0; +uniform float fade_end = 56.0; +varying float palette_variation; + +void vertex() { + vec3 anchor = MODEL_MATRIX[3].xyz; + float distance_to_camera = distance(anchor, CAMERA_POSITION_WORLD); + float presence = 1.0 - smoothstep(fade_begin, fade_end, distance_to_camera); + float height = max(VERTEX.y, 0.0); + float phase = dot(anchor.xz, vec2(0.38, 0.29)); + float gust = sin(TIME * 1.15 + phase) * 0.65 + sin(TIME * 0.58 + phase * 0.42) * 0.35; + VERTEX *= presence; + vec3 world_vertex = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz; + world_vertex.xz += normalize(wind_direction) * wind_amount * gust * height * height * presence; + VERTEX = (VIEW_MATRIX * vec4(world_vertex, 1.0)).xyz; + vec3 local_normal = normalize(NORMAL + vec3(0.0, 1.6, 0.0)); + NORMAL = normalize((MODELVIEW_MATRIX * vec4(local_normal, 0.0)).xyz); + palette_variation = mix(0.92, 1.06, INSTANCE_CUSTOM.r); +} + +void fragment() { + ALBEDO = COLOR.rgb * palette_variation; + ROUGHNESS = 1.0; +} + +void light() { + float sunlight = smoothstep(-0.35, 0.55, dot(NORMAL, LIGHT)); + vec3 shade = mix(vec3(0.43, 0.58, 0.55), vec3(1.0, 0.98, 0.90), sunlight); + DIFFUSE_LIGHT += shade * LIGHT_COLOR * ATTENUATION / PI; +} diff --git a/world/jajce/materials/meadow_flowers.gdshader.uid b/world/jajce/materials/meadow_flowers.gdshader.uid new file mode 100644 index 0000000..8bef437 --- /dev/null +++ b/world/jajce/materials/meadow_flowers.gdshader.uid @@ -0,0 +1 @@ +uid://b1r81tjhi1o0y diff --git a/world/jajce/materials/storybook_sky.gdshader b/world/jajce/materials/storybook_sky.gdshader index 1672fc1..fed7dea 100644 --- a/world/jajce/materials/storybook_sky.gdshader +++ b/world/jajce/materials/storybook_sky.gdshader @@ -1,27 +1,26 @@ shader_type sky; -uniform vec3 sky_top_color : source_color = vec3(0.27, 0.57, 0.74); -uniform vec3 sky_horizon_color : source_color = vec3(0.79, 0.88, 0.82); +// Original painted panorama; no volume or per-frame sky pass. +uniform sampler2D painted_panorama : source_color, filter_linear_mipmap, repeat_enable; +uniform vec3 sky_top_color : source_color = vec3(0.12, 0.49, 0.69); +uniform vec3 sky_horizon_color : source_color = vec3(0.65, 0.84, 0.84); uniform vec3 ground_bottom_color : source_color = vec3(0.12, 0.17, 0.12); -uniform vec3 ground_horizon_color : source_color = vec3(0.56, 0.69, 0.57); +uniform vec3 ground_horizon_color : source_color = vec3(0.65, 0.84, 0.84); uniform float daylight : hint_range(0.0, 1.0) = 1.0; - -float hash21(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); } -float noise(vec2 p) { - vec2 i = floor(p); - vec2 f = fract(p); - f = f * f * (3.0 - 2.0 * f); - return mix(mix(hash21(i), hash21(i + vec2(1.0, 0.0)), f.x), mix(hash21(i + vec2(0.0, 1.0)), hash21(i + vec2(1.0)), f.x), f.y); -} +uniform float golden_hour : hint_range(0.0, 1.0) = 0.0; void sky() { float height = max(EYEDIR.y, 0.0); - vec3 sky_color = mix(sky_horizon_color, sky_top_color, pow(height, 0.48)); - vec2 p = EYEDIR.xz / max(0.17, height) * 1.15; - float cloud = noise(p) * 0.65 + noise(p * 2.1 + 7.3) * 0.25 + noise(p * 4.3) * 0.10; - float mask = smoothstep(0.55, 0.64, cloud) * smoothstep(0.02, 0.20, height); - vec3 cloud_shadow = mix(vec3(0.08, 0.10, 0.19), vec3(0.70, 0.80, 0.80), daylight); - vec3 cloud_light = mix(vec3(0.15, 0.18, 0.29), vec3(1.0, 0.98, 0.87), daylight); - vec3 cloud_color = mix(cloud_shadow, cloud_light, smoothstep(0.61, 0.77, cloud)); - sky_color = mix(sky_color, cloud_color, mask); + vec3 sky_gradient = mix(sky_horizon_color, sky_top_color, pow(height, 0.45)); + // Art horizon is at 69% of the sheet. Remap only the upper hemisphere; + // the ground bounce uses the day/night palette and never paints terrain. + vec2 panorama_uv = vec2(fract(SKY_COORDS.x * 2.0 + 0.40), clamp((SKY_COORDS.y - 0.26) * 2.875, 0.0, 0.69)); + vec3 painted = texture(painted_panorama, panorama_uv).rgb; + vec3 seam_color = (texture(painted_panorama, vec2(0.001, panorama_uv.y)).rgb + texture(painted_panorama, vec2(0.999, panorama_uv.y)).rgb) * 0.5; + painted = mix(seam_color, painted, smoothstep(0.0, 0.025, min(panorama_uv.x, 1.0 - panorama_uv.x))); + painted = mix(painted, painted * vec3(1.18, 0.79, 0.66), golden_hour * 0.50); + vec3 night_paint = painted * vec3(0.045, 0.07, 0.14) + sky_gradient * 0.12; + vec3 sky_color = mix(night_paint, painted, daylight); + // Dissolve the panorama edge into the haze at the actual horizon. + sky_color = mix(sky_gradient, sky_color, smoothstep(0.0, 0.075, height)); COLOR = EYEDIR.y >= 0.0 ? sky_color : mix(ground_horizon_color, ground_bottom_color, pow(-EYEDIR.y, 0.4)); } diff --git a/world/jajce/materials/water.gdshader b/world/jajce/materials/water.gdshader index 85c9c2e..68b9076 100644 --- a/world/jajce/materials/water.gdshader +++ b/world/jajce/materials/water.gdshader @@ -1,42 +1,81 @@ shader_type spatial; render_mode blend_mix, depth_prepass_alpha, cull_disabled, diffuse_burley, specular_schlick_ggx; -uniform float wave_strength : hint_range(0.0, 0.3) = 0.055; +uniform float wave_strength : hint_range(0.0, 0.3) = 0.025; 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 float flow_speed : hint_range(0.0, 2.0) = 0.28; +uniform bool pool_surface = false; +uniform float pool_outlet_z = -20.0; +uniform vec3 deep_color : source_color = vec3(0.035, 0.34, 0.54); +uniform vec3 shallow_color : source_color = vec3(0.12, 0.65, 0.69); +uniform vec3 sunlit_color : source_color = vec3(0.57, 0.83, 0.88); +uniform vec3 foam_color : source_color = vec3(0.9, 0.96, 0.91); + +varying vec3 water_position; + + +float painted_noise(vec2 p) { + vec2 cell = floor(p); + vec2 blend = fract(p); + blend = blend * blend * (3.0 - 2.0 * blend); + vec4 corners = fract(sin(vec4( + dot(cell, vec2(127.1, 311.7)), + dot(cell + vec2(1.0, 0.0), vec2(127.1, 311.7)), + dot(cell + vec2(0.0, 1.0), vec2(127.1, 311.7)), + dot(cell + vec2(1.0), vec2(127.1, 311.7)) + )) * 43758.5453); + return mix(mix(corners.x, corners.y, blend.x), mix(corners.z, corners.w, blend.x), blend.y); +} void vertex() { - 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); + // World coordinates keep the pool and both ribbons at the same ripple scale. + water_position = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz; + float broad_wave = sin(water_position.x * 0.42 + water_position.z * 0.18 + TIME * wave_speed); + float crossing_wave = cos(water_position.x * 0.7 - water_position.z * 0.31 + TIME * wave_speed * 0.73); VERTEX.y += (broad_wave + crossing_wave * 0.45) * wave_strength; } void fragment() { - 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; - - 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); + // The ribbon owns the outlet so two transparent surfaces never overlap. + if (pool_surface && water_position.z >= pool_outlet_z) { + discard; + } + vec2 flow = water_position.xz - vec2(0.0, TIME * flow_speed); + float broad_patch = painted_noise(flow * vec2(0.18, 0.3)); + float small_patch = painted_noise(flow * vec2(0.64, 0.85)); + float ripple_phase = flow.y * 3.2 + sin(flow.x * 0.83) * 1.2 + small_patch * 0.8; + float ribbon = sin(ripple_phase); + float stroke_width = max(fwidth(ribbon) * 0.7, 0.012); + float strokes = smoothstep(0.975 - stroke_width, 0.975 + stroke_width, ribbon); + strokes *= smoothstep(0.58, 0.8, painted_noise(flow * vec2(1.1, 1.8))) * 0.16; + strokes *= 1.0 - smoothstep(0.28, 0.8, fwidth(ripple_phase)); + vec3 ripple_normal = normalize(vec3( + cos(flow.x * 0.83 + flow.y * 0.37 + broad_patch * 6.0) * 0.006, + 1.0, + cos(ripple_phase + small_patch * 5.0) * 0.008 + )); + NORMAL = normalize((VIEW_MATRIX * vec4(ripple_normal, 0.0)).xyz); + float fresnel = pow(1.0 - clamp(dot(NORMAL, normalize(VIEW)), 0.0, 1.0), 2.6); + // Painted sky patches need no screen/depth texture and work in Compatibility. + vec3 world_view = normalize((INV_VIEW_MATRIX * vec4(VIEW, 0.0)).xyz); + vec3 reflection = reflect(-world_view, ripple_normal); + vec2 sky_uv = reflection.xz / max(reflection.y + 0.35, 0.25); + float clouds = painted_noise(sky_uv * 2.0 + vec2(0.7, 2.4)); + clouds = smoothstep(0.49, 0.77, clouds + painted_noise(sky_uv * 4.5) * 0.18); + float bank_distance = min(UV.x, 1.0 - UV.x) * 2.0; + if (pool_surface) { + bank_distance = 1.0 - length(UV * 2.0 - 1.0); + } + float bank_foam = (1.0 - smoothstep(0.01, 0.045, bank_distance)); + bank_foam *= smoothstep(0.4, 0.75, small_patch) * 0.48; + vec3 water_color = mix(deep_color, shallow_color, 0.19 + broad_patch * 0.2); + water_color = mix(water_color, sunlit_color, clouds * (0.13 + fresnel * 0.35)); + water_color = mix(water_color, foam_color, strokes + bank_foam); ALBEDO = water_color; - 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); + ROUGHNESS = 0.38 + broad_patch * 0.08; + SPECULAR = 0.14; + ALPHA = smoothstep(0.0, 0.018, bank_distance) * 0.97; } diff --git a/world/jajce/materials/water_foam.gdshader b/world/jajce/materials/water_foam.gdshader new file mode 100644 index 0000000..874c3a7 --- /dev/null +++ b/world/jajce/materials/water_foam.gdshader @@ -0,0 +1,25 @@ +shader_type spatial; +render_mode blend_mix, depth_prepass_alpha, cull_disabled, diffuse_burley; + +uniform bool circular_wash = false; +uniform vec3 foam_color : source_color = vec3(0.83, 0.94, 0.93); + + +void fragment() { + vec2 p = UV * 2.0 - 1.0; + float wash = sin(p.x * 18.0 + sin(p.y * 12.0 + TIME * 0.8) * 1.5); + wash *= sin(p.y * 23.0 - TIME * 1.4 + p.x * 3.0); + float mask = (1.0 - smoothstep(0.45, 1.0, abs(p.x))); + mask *= 1.0 - smoothstep(0.2, 1.0, abs(p.y)); + if (circular_wash) { + float radius = length(p); + float angle = atan(p.y, p.x); + float broken_arcs = sin(radius * 32.0 - TIME * 0.75 + sin(angle * 5.0) * 0.6); + mask = smoothstep(0.65, 0.94, broken_arcs); + mask *= smoothstep(0.1, 0.35, radius) * (1.0 - smoothstep(0.65, 0.98, radius)); + mask *= smoothstep(-0.3, 0.8, sin(angle * 3.0 + radius * 9.0)); + } + ALBEDO = foam_color; + ROUGHNESS = 0.65; + ALPHA = mask * smoothstep(-0.45, 0.6, wash) * 0.48; +} diff --git a/world/jajce/materials/water_foam.gdshader.uid b/world/jajce/materials/water_foam.gdshader.uid new file mode 100644 index 0000000..3a347e4 --- /dev/null +++ b/world/jajce/materials/water_foam.gdshader.uid @@ -0,0 +1 @@ +uid://bcw0dj01f0m3h diff --git a/world/jajce/materials/waterfall.gdshader b/world/jajce/materials/waterfall.gdshader index cf8e7e1..07de4b5 100644 --- a/world/jajce/materials/waterfall.gdshader +++ b/world/jajce/materials/waterfall.gdshader @@ -2,9 +2,9 @@ shader_type spatial; 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 shadow_color : source_color = vec3(0.11, 0.39, 0.54); +uniform vec3 water_color : source_color = vec3(0.39, 0.73, 0.82); +uniform vec3 light_color : source_color = vec3(0.85, 0.96, 0.94); uniform vec3 foam_color : source_color = vec3(0.91, 0.97, 0.9); @@ -22,17 +22,16 @@ void fragment() { 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 broken_foam = smoothstep(0.61, 0.94, 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 edge_fade = smoothstep(0.0, 0.08, UV.x) * (1.0 - smoothstep(0.92, 1.0, UV.x)); vec3 color = mix(shadow_color, water_color, 0.5 + thread * 0.35); - color = mix(color, light_color, broken_foam * 0.5); + color = mix(color, light_color, thread * 0.35 + broken_foam * 0.35); color = mix(color, foam_color, base_foam * (0.38 + broken_foam * 0.42)); ALBEDO = color; 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); + ALPHA = edge_fade * mix(0.76, 0.95, thread * 0.56 + base_foam * 0.3); } diff --git a/world/jajce/materials/wind.gdshader b/world/jajce/materials/wind.gdshader index 8872aef..c80fe02 100644 --- a/world/jajce/materials/wind.gdshader +++ b/world/jajce/materials/wind.gdshader @@ -10,8 +10,11 @@ uniform float gust_speed : hint_range(0.0, 1.0) = 0.12; uniform float gust_strength : hint_range(0.0, 0.8) = 0.35; uniform float flutter_strength : hint_range(0.0, 0.05) = 0.012; uniform float wind_phase : hint_range(0.0, 6.2832) = 0.0; +uniform float wind_base_height = -1.2; +uniform float wind_full_height = 1.2; uniform vec3 albedo : source_color = vec3(0.19, 0.38, 0.17); uniform float roughness : hint_range(0.0, 1.0) = 0.9; +uniform float painted_vertex_weight : hint_range(0.0, 1.0) = 0.0; uniform vec3 contact_root = vec3(0.0); uniform float contact_strength = 0.0; uniform int interactor_count = 0; @@ -20,8 +23,7 @@ varying vec3 paint_position; void vertex() { paint_position = VERTEX; - float half_height = 1.2; - float height_factor = clamp((VERTEX.y + half_height) / (half_height * 2.0), 0.0, 1.0); + float height_factor = clamp((VERTEX.y - wind_base_height) / max(wind_full_height - wind_base_height, 0.01), 0.0, 1.0); height_factor = height_factor * height_factor; vec2 direction = normalize(wind_direction); @@ -32,8 +34,10 @@ void vertex() { float bend = sin(TIME * wind_speed + world_phase) * wind_strength * gust; float flutter = sin(TIME * 1.7 + world_phase * 1.9 + VERTEX.y * 2.0) * flutter_strength; - VERTEX.xz += direction * bend * height_factor; - VERTEX.xz += crosswind * flutter * height_factor; + // Crown orientation varies, but all foliage follows the same world breeze. + vec3 world_bend = vec3(direction.x, 0.0, direction.y) * bend; + world_bend += vec3(crosswind.x, 0.0, crosswind.y) * flutter; + VERTEX += (inverse(MODEL_MATRIX) * vec4(world_bend, 0.0)).xyz * height_factor; if (foliage_trails_enabled && contact_strength > 0.0) { // One bend for the whole bush, including fruit; its base remains planted. vec3 contact = sample_foliage_trail(contact_root); @@ -69,7 +73,10 @@ void vertex() { void fragment() { float patches = sin(paint_position.x * 6.0 + sin(paint_position.z * 7.0)) * sin(paint_position.y * 5.0); float top = smoothstep(-0.65, 0.8, paint_position.y); - ALBEDO = albedo * mix(0.85, 1.17, top) * (1.0 + smoothstep(0.2, 0.4, patches) * 0.10); + vec3 pigment = mix(vec3(1.0), COLOR.rgb, painted_vertex_weight); + vec3 shade_tint = mix(vec3(0.77, 0.95, 1.01), vec3(1.10, 1.06, 0.87), top); + ALBEDO = albedo * pigment * shade_tint * mix(0.91, 1.13, top); + ALBEDO *= 1.0 + smoothstep(0.18, 0.65, patches) * 0.07; ROUGHNESS = roughness; SPECULAR = 0.0; }