diff --git a/art/storybook/.gdignore b/art/storybook/.gdignore new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/art/storybook/.gdignore @@ -0,0 +1 @@ + diff --git a/art/storybook/badger.blend b/art/storybook/badger.blend new file mode 100644 index 0000000..0bcf80d Binary files /dev/null and b/art/storybook/badger.blend differ diff --git a/art/storybook/fox.blend b/art/storybook/fox.blend new file mode 100644 index 0000000..c3c06ad Binary files /dev/null and b/art/storybook/fox.blend differ diff --git a/art/storybook/meadow_grass.blend b/art/storybook/meadow_grass.blend new file mode 100644 index 0000000..c5c28b7 Binary files /dev/null and b/art/storybook/meadow_grass.blend differ diff --git a/art/storybook/otter.blend b/art/storybook/otter.blend new file mode 100644 index 0000000..d3953f4 Binary files /dev/null and b/art/storybook/otter.blend differ diff --git a/art/storybook/rabbit.blend b/art/storybook/rabbit.blend new file mode 100644 index 0000000..99e2daa Binary files /dev/null and b/art/storybook/rabbit.blend differ diff --git a/assets/storybook/README.md b/assets/storybook/README.md new file mode 100644 index 0000000..cbd495c --- /dev/null +++ b/assets/storybook/README.md @@ -0,0 +1,83 @@ +# Woodland storybook art + +Original Blender models for The Steward's warm, painted animation direction. +The runtime cast is a fox traveler, rabbit gardener, bespectacled badger and +otter in oversized coats, scarves, boots and satchels. These are original +characters, not models of existing film characters. + +`art/storybook/` contains the editable Blender 5.1 sources. Its `.gdignore` +keeps Blender out of Godot's runtime import dependency chain. This directory +contains portable GLBs and the extracted grass mesh consumed by Terrain3D. + +## Rebuild + +From the project root, with Blender and Godot 4.7 installed: + +```sh +"$BLENDER_BIN" --background --python tools/art/build_storybook_assets.py +"$GODOT_BIN" --headless --editor --path "$PWD" --import +"$GODOT_BIN" --headless --path "$PWD" --script tools/import_storybook_grass.gd +``` + +On macOS the binaries used for this pass were +`/Applications/Blender.app/Contents/MacOS/Blender` and +`/Applications/Godot.app/Contents/MacOS/Godot`. Headless CI should use the +isolated profile configured by `tools/quality.sh`. + +## Mesh and material contract + +- The seven-blade meadow tuft has **35 triangles**, one surface, rooted Y=0, + and UV.y=0 at each root / 1 at each tip. Wind and actor bending run on the + GPU. The material is opaque and double-sided, with no alpha cards, texture + lookups, per-blade nodes, shadow casting, or outline pass. +- Terrain3D owns the existing nine camera-local emitter cells: 10,404 tuft + slots on High, 5,184 on Balanced, hidden and stopped on Low. Terrain masks + and distance fading further reduce visible grass. Four scene-derived path + clearings collapse blades around the walking strips. Changing density does + not alter the terrain, navigation, resource state or collision. +- Each animal has eight mesh surfaces, **7,884–10,050 triangles** total, and + vertex colors. `mesh_budget.json` is emitted by the Blender generator. + Vertex alpha is a clothing tint mask; the cel shader never writes ALPHA. +- `AnimalAppearance` derives NPC species from stable ID modulo four; the + player is a fox. It shares imported meshes, creates an isolated material + per actor and tints clothing from the existing profession definition. + `Body`, `Head`, `Hair`, four limbs and `Tail` preserve the visual controllers' + animation slots. Shoulder/hip pivots support the existing velocity-driven + gait. Ears, face details, boots and scarves are joined into those surfaces. +- Inventory sacks, carried logs, profession tools, task glyphs, trust reactions, + death and player sword cues remain controlled by their existing state paths. + Animal species here is appearance only: no animal simulation classification, + RNG calls, saved fields, migration or gameplay mechanics are introduced. + +## Review captures + +```sh +"$GODOT_BIN" --path "$PWD" --script tools/capture_storybook.gd -- --cast +"$GODOT_BIN" --path "$PWD" --script tools/capture_storybook.gd +``` + +These require a real renderer. The first writes `storybook_cast.png`; the +second captures the running main scene at a controlled daytime presentation, +then writes gameplay/valley PNGs and a small profile sample to `docs/baselines/`. +It pauses simulation only in the capture process and does not save anything. +The gameplay camera defaults remain unchanged. The matched environment camera +can also be captured with `tools/capture_jajce_lookdev.gd -- --hide-debug-labels +--output=res://docs/baselines/storybook_after.png`. + +Profile samples are local wall-frame latency at a static camera with six NPC +visuals, including vsync. They are not isolated GPU timings or weak-PC proof. + +The full macOS quality gate passed on Godot 4.7, including all scenarios, +Compatibility profile checks and 180 GUT tests / 2,258 assertions. Native +Metal captures were visually reviewed. The character shader also rendered on +Compatibility, whose color/tonemapping differs substantially from Metal; the +approved color reference is `storybook_cast.png` (Metal). + +Native Metal shutdown reports one `ParticlesShaderRD` / material Shader RID +retained at exit. The identical diagnostic was reproduced by importing and +running untouched commit `6aae41d` in a separate temporary directory. The +existing headless gate allowlist was not changed. Import/startup diagnostics +and the baseline comparison are in ignored `logs/quality/storybook-*.log`. + +Shader interfaces follow the official [Godot 4.7 spatial shader contract](https://docs.godotengine.org/en/4.7/tutorials/shaders/shader_reference/spatial_shader.html). +The exporter uses Blender's [glTF export API](https://docs.blender.org/api/main/bpy.ops.export_scene.html). diff --git a/assets/storybook/badger.glb b/assets/storybook/badger.glb new file mode 100644 index 0000000..5688195 Binary files /dev/null and b/assets/storybook/badger.glb differ diff --git a/assets/storybook/badger.glb.import b/assets/storybook/badger.glb.import new file mode 100644 index 0000000..2753275 --- /dev/null +++ b/assets/storybook/badger.glb.import @@ -0,0 +1,45 @@ +[remap] + +importer="scene" +importer_version=1 +type="PackedScene" +uid="uid://buehm0sth6etc" +path="res://.godot/imported/badger.glb-4a20c61fae6d6e8d68c151cba8d056d5.scn" + +[deps] + +source_file="res://assets/storybook/badger.glb" +dest_files=["res://.godot/imported/badger.glb-4a20c61fae6d6e8d68c151cba8d056d5.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/storybook/fox.glb b/assets/storybook/fox.glb new file mode 100644 index 0000000..1e58502 Binary files /dev/null and b/assets/storybook/fox.glb differ diff --git a/assets/storybook/fox.glb.import b/assets/storybook/fox.glb.import new file mode 100644 index 0000000..96e5265 --- /dev/null +++ b/assets/storybook/fox.glb.import @@ -0,0 +1,45 @@ +[remap] + +importer="scene" +importer_version=1 +type="PackedScene" +uid="uid://cbne2ol0h0uku" +path="res://.godot/imported/fox.glb-972d78ae35ada569a710c17f00c10a3e.scn" + +[deps] + +source_file="res://assets/storybook/fox.glb" +dest_files=["res://.godot/imported/fox.glb-972d78ae35ada569a710c17f00c10a3e.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/storybook/meadow_grass.glb b/assets/storybook/meadow_grass.glb new file mode 100644 index 0000000..5a9aa97 Binary files /dev/null and b/assets/storybook/meadow_grass.glb differ diff --git a/assets/storybook/meadow_grass.glb.import b/assets/storybook/meadow_grass.glb.import new file mode 100644 index 0000000..fc83016 --- /dev/null +++ b/assets/storybook/meadow_grass.glb.import @@ -0,0 +1,45 @@ +[remap] + +importer="scene" +importer_version=1 +type="PackedScene" +uid="uid://bd6mrof50g4wn" +path="res://.godot/imported/meadow_grass.glb-0321cd60906212db637f52f1f1493ea1.scn" + +[deps] + +source_file="res://assets/storybook/meadow_grass.glb" +dest_files=["res://.godot/imported/meadow_grass.glb-0321cd60906212db637f52f1f1493ea1.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/storybook/meadow_grass.res b/assets/storybook/meadow_grass.res new file mode 100644 index 0000000..08551ae Binary files /dev/null and b/assets/storybook/meadow_grass.res differ diff --git a/assets/storybook/mesh_budget.json b/assets/storybook/mesh_budget.json new file mode 100644 index 0000000..01a8d8f --- /dev/null +++ b/assets/storybook/mesh_budget.json @@ -0,0 +1,27 @@ +{ + "fox": { + "triangles": 7884, + "surfaces": 8, + "bytes": 271636 + }, + "rabbit": { + "triangles": 9060, + "surfaces": 8, + "bytes": 314636 + }, + "badger": { + "triangles": 10050, + "surfaces": 8, + "bytes": 345484 + }, + "otter": { + "triangles": 9088, + "surfaces": 8, + "bytes": 314108 + }, + "meadow_grass": { + "triangles": 35, + "surfaces": 1, + "bytes": 3648 + } +} diff --git a/assets/storybook/otter.glb b/assets/storybook/otter.glb new file mode 100644 index 0000000..b5302ab Binary files /dev/null and b/assets/storybook/otter.glb differ diff --git a/assets/storybook/otter.glb.import b/assets/storybook/otter.glb.import new file mode 100644 index 0000000..435850c --- /dev/null +++ b/assets/storybook/otter.glb.import @@ -0,0 +1,45 @@ +[remap] + +importer="scene" +importer_version=1 +type="PackedScene" +uid="uid://6f01q2ru2sq4" +path="res://.godot/imported/otter.glb-92d54495ed52927d77a0a5213ac92330.scn" + +[deps] + +source_file="res://assets/storybook/otter.glb" +dest_files=["res://.godot/imported/otter.glb-92d54495ed52927d77a0a5213ac92330.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/storybook/rabbit.glb b/assets/storybook/rabbit.glb new file mode 100644 index 0000000..06db172 Binary files /dev/null and b/assets/storybook/rabbit.glb differ diff --git a/assets/storybook/rabbit.glb.import b/assets/storybook/rabbit.glb.import new file mode 100644 index 0000000..7c264ed --- /dev/null +++ b/assets/storybook/rabbit.glb.import @@ -0,0 +1,45 @@ +[remap] + +importer="scene" +importer_version=1 +type="PackedScene" +uid="uid://bywpllx00b6aq" +path="res://.godot/imported/rabbit.glb-ed7bf640394b68863b9dc6437e0f1808.scn" + +[deps] + +source_file="res://assets/storybook/rabbit.glb" +dest_files=["res://.godot/imported/rabbit.glb-ed7bf640394b68863b9dc6437e0f1808.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/docs/FEATURE_PRESENTATION_PERFORMANCE.md b/docs/FEATURE_PRESENTATION_PERFORMANCE.md index d3015f7..d54e7ed 100644 --- a/docs/FEATURE_PRESENTATION_PERFORMANCE.md +++ b/docs/FEATURE_PRESENTATION_PERFORMANCE.md @@ -51,6 +51,25 @@ selection never forces unsupported volumetric fog on. ## Runtime hot paths already bounded +The woodland storybook art pass adds portable Blender animal meshes and an +opaque 35-triangle, seven-blade grass tuft. The existing camera-local grass +field and quality controls own its instance budget; four loaded path strips +provide disposable grass-clearance data after material isolation. Animal +appearance is derived from stable NPC IDs, shares mesh resources, and retains +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 +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). + +`docs/baselines/storybook_*.png` records the native Metal review. The accompanying +`storybook_render_metrics.json` records a bounded local 1600×900 static-camera +sample on Apple M1 Max with six NPCs and High/Balanced/Low settings. Wall-frame +latency includes vsync and does not close the weak-PC evidence gap below. + - Player interaction and villager inspection probes are throttled/event-driven; they no longer rebuild all candidate data every rendered frame. - `LoadedResourceSpatialIndex` bounds finite-resource discovery. diff --git a/docs/baselines/storybook_after.png b/docs/baselines/storybook_after.png new file mode 100644 index 0000000..dd0a360 Binary files /dev/null and b/docs/baselines/storybook_after.png differ diff --git a/docs/baselines/storybook_before.png b/docs/baselines/storybook_before.png new file mode 100644 index 0000000..bded9fc Binary files /dev/null and b/docs/baselines/storybook_before.png differ diff --git a/docs/baselines/storybook_cast.png b/docs/baselines/storybook_cast.png new file mode 100644 index 0000000..e425a83 Binary files /dev/null and b/docs/baselines/storybook_cast.png differ diff --git a/docs/baselines/storybook_cast_compatibility.png b/docs/baselines/storybook_cast_compatibility.png new file mode 100644 index 0000000..9687028 Binary files /dev/null and b/docs/baselines/storybook_cast_compatibility.png differ diff --git a/docs/baselines/storybook_gameplay.png b/docs/baselines/storybook_gameplay.png new file mode 100644 index 0000000..d10bff4 Binary files /dev/null and b/docs/baselines/storybook_gameplay.png differ diff --git a/docs/baselines/storybook_render_metrics.json b/docs/baselines/storybook_render_metrics.json new file mode 100644 index 0000000..35537a4 --- /dev/null +++ b/docs/baselines/storybook_render_metrics.json @@ -0,0 +1,34 @@ +{ + "renderer": "metal", + "resolution": "1600x900", + "samples": [ + { + "active_npcs": 6, + "draw_calls": 702.0, + "frame_wall_p50_ms": 16.646, + "frame_wall_p95_ms": 16.903, + "objects": 1114.0, + "primitives": 684656.0, + "profile": "high" + }, + { + "active_npcs": 6, + "draw_calls": 613.0, + "frame_wall_p50_ms": 16.665, + "frame_wall_p95_ms": 16.93, + "objects": 1031.0, + "primitives": 486192.0, + "profile": "balanced" + }, + { + "active_npcs": 6, + "draw_calls": 592.0, + "frame_wall_p50_ms": 16.659, + "frame_wall_p95_ms": 16.989, + "objects": 999.0, + "primitives": 377596.0, + "profile": "low" + } + ], + "workload": "Paused static valley; wall-frame latency includes vsync, not isolated GPU time" +} diff --git a/docs/baselines/storybook_valley.png b/docs/baselines/storybook_valley.png new file mode 100644 index 0000000..bc598a4 Binary files /dev/null and b/docs/baselines/storybook_valley.png differ diff --git a/player/PlayerVisual.tscn b/player/PlayerVisual.tscn index bc9885f..d7a37ce 100644 --- a/player/PlayerVisual.tscn +++ b/player/PlayerVisual.tscn @@ -1,72 +1,7 @@ -[gd_scene load_steps=17 format=3] +[gd_scene load_steps=6 format=3] [ext_resource type="Script" path="res://player/player_visual.gd" id="1_visual"] -[sub_resource type="StandardMaterial3D" id="Material_tunic"] -albedo_color = Color(0.18, 0.46, 0.48, 1) -roughness = 0.84 - -[sub_resource type="CylinderMesh" id="Mesh_body"] -material = SubResource("Material_tunic") -top_radius = 0.28 -bottom_radius = 0.42 -height = 0.9 - -[sub_resource type="StandardMaterial3D" id="Material_skin"] -albedo_color = Color(0.84, 0.61, 0.39, 1) -roughness = 0.88 - -[sub_resource type="SphereMesh" id="Mesh_head"] -material = SubResource("Material_skin") -radius = 0.29 -height = 0.56 - -[sub_resource type="StandardMaterial3D" id="Material_hair"] -albedo_color = Color(0.2, 0.105, 0.05, 1) -roughness = 0.92 - -[sub_resource type="CylinderMesh" id="Mesh_hair"] -material = SubResource("Material_hair") -top_radius = 0.24 -bottom_radius = 0.29 -height = 0.18 - -[sub_resource type="CylinderMesh" id="Mesh_arm"] -material = SubResource("Material_tunic") -top_radius = 0.095 -bottom_radius = 0.11 -height = 0.58 - -[sub_resource type="SphereMesh" id="Mesh_hand"] -material = SubResource("Material_skin") -radius = 0.12 -height = 0.22 - -[sub_resource type="StandardMaterial3D" id="Material_pants"] -albedo_color = Color(0.15, 0.21, 0.24, 1) -roughness = 0.9 - -[sub_resource type="CapsuleMesh" id="Mesh_leg"] -material = SubResource("Material_pants") -radius = 0.115 -height = 0.62 - -[sub_resource type="StandardMaterial3D" id="Material_boot"] -albedo_color = Color(0.13, 0.075, 0.04, 1) -roughness = 0.95 - -[sub_resource type="BoxMesh" id="Mesh_foot"] -material = SubResource("Material_boot") -size = Vector3(0.23, 0.15, 0.36) - -[sub_resource type="StandardMaterial3D" id="Material_accent"] -albedo_color = Color(0.86, 0.4, 0.18, 1) -roughness = 0.82 - -[sub_resource type="BoxMesh" id="Mesh_scarf"] -material = SubResource("Material_accent") -size = Vector3(0.48, 0.1, 0.38) - [sub_resource type="StandardMaterial3D" id="Material_sword"] albedo_color = Color(0.72, 0.74, 0.78, 1) roughness = 0.32 @@ -85,67 +20,29 @@ top_radius = 0.045 bottom_radius = 0.045 height = 0.16 -[sub_resource type="SphereMesh" id="Mesh_satchel"] -material = SubResource("Material_accent") -radius = 0.22 -height = 0.34 - [node name="PlayerVisual" type="Node3D"] script = ExtResource("1_visual") [node name="Body" type="MeshInstance3D" parent="."] position = Vector3(0, 1.05, 0) -mesh = SubResource("Mesh_body") - -[node name="Scarf" type="MeshInstance3D" parent="."] -position = Vector3(0, 1.42, 0.02) -mesh = SubResource("Mesh_scarf") [node name="Head" type="MeshInstance3D" parent="."] position = Vector3(0, 1.68, 0) -mesh = SubResource("Mesh_head") [node name="Hair" type="MeshInstance3D" parent="."] position = Vector3(0, 1.9, -0.015) -mesh = SubResource("Mesh_hair") [node name="ArmLeft" type="MeshInstance3D" parent="."] -position = Vector3(-0.39, 1.08, 0) -rotation = Vector3(0, 0, -0.139626) -mesh = SubResource("Mesh_arm") - -[node name="Hand" type="MeshInstance3D" parent="ArmLeft"] -position = Vector3(0, -0.35, 0) -mesh = SubResource("Mesh_hand") +position = Vector3(-0.39, 1.3, 0) [node name="ArmRight" type="MeshInstance3D" parent="."] -position = Vector3(0.39, 1.08, 0) -rotation = Vector3(0, 0, 0.139626) -mesh = SubResource("Mesh_arm") - -[node name="Hand" type="MeshInstance3D" parent="ArmRight"] -position = Vector3(0, -0.35, 0) -mesh = SubResource("Mesh_hand") +position = Vector3(0.39, 1.3, 0) [node name="LegLeft" type="MeshInstance3D" parent="."] -position = Vector3(-0.18, 0.43, 0) -mesh = SubResource("Mesh_leg") - -[node name="Foot" type="MeshInstance3D" parent="LegLeft"] -position = Vector3(0, -0.28, 0.09) -mesh = SubResource("Mesh_foot") +position = Vector3(-0.18, 0.61, 0) [node name="LegRight" type="MeshInstance3D" parent="."] -position = Vector3(0.18, 0.43, 0) -mesh = SubResource("Mesh_leg") - -[node name="Foot" type="MeshInstance3D" parent="LegRight"] -position = Vector3(0, -0.28, 0.09) -mesh = SubResource("Mesh_foot") - -[node name="Satchel" type="MeshInstance3D" parent="."] -position = Vector3(-0.43, 0.87, -0.04) -mesh = SubResource("Mesh_satchel") +position = Vector3(0.18, 0.61, 0) [node name="Sword" type="Node3D" parent="."] position = Vector3(0.52, 1.22, 0.12) @@ -158,3 +55,6 @@ mesh = SubResource("Mesh_blade") [node name="Grip" type="MeshInstance3D" parent="Sword"] position = Vector3(0, -0.05, 0) mesh = SubResource("Mesh_grip") + +[node name="Tail" type="MeshInstance3D" parent="."] +position = Vector3(0, 0.8, -0.22) diff --git a/player/npc/NpcVisual.gd b/player/npc/NpcVisual.gd index ebb7ce2..7bf851c 100644 --- a/player/npc/NpcVisual.gd +++ b/player/npc/NpcVisual.gd @@ -4,9 +4,9 @@ signal arrived_at_target(sim_id: int) signal navigation_failed(sim_id: int) signal position_changed(sim_id: int, active_position: Vector3) -const TRUST_REACTION_BASE_POSITION := Vector3(0.0, 2.72, 0.0) +const TRUST_REACTION_BASE_POSITION := Vector3(0.0, 3.25, 0.0) const TRUST_REACTION_START_SCALE := Vector3(0.24, 0.24, 0.24) -const CONCERN_BASE_POSITION := Vector3(-0.4, 2.72, 0.0) +const CONCERN_BASE_POSITION := Vector3(-0.4, 3.25, 0.0) @export var debug_logs := false @@ -76,7 +76,7 @@ func _process(delta: float) -> void: return glyph_phase = fmod(glyph_phase + delta * 2.2, TAU) if task_glyph_root.visible: - task_glyph_root.position.y = 2.35 + sin(glyph_phase) * 0.045 + task_glyph_root.position.y = 2.9 + sin(glyph_phase) * 0.045 task_glyph_root.rotation.y = glyph_phase * 0.35 if opportunity_concern_root.visible: concern_phase = fmod(concern_phase + delta * 1.65, TAU) @@ -123,16 +123,7 @@ func setup_from_sim(npc: SimNPC) -> void: func _apply_personal_details() -> void: - var hair_colors := [ - Color(0.16, 0.09, 0.055, 1.0), - Color(0.29, 0.16, 0.075, 1.0), - Color(0.09, 0.065, 0.05, 1.0), - Color(0.42, 0.27, 0.12, 1.0), - ] - var hair_material := StandardMaterial3D.new() - hair_material.albedo_color = hair_colors[maxi(sim_id, 0) % hair_colors.size()] - hair_material.roughness = 0.9 - hair_mesh.material_override = hair_material + AnimalAppearance.apply_to(self, sim_id, true) func _apply_profession_presentation() -> void: @@ -142,12 +133,7 @@ func _apply_profession_presentation() -> void: profession_prop.visible = false return - var body_material := StandardMaterial3D.new() - body_material.albedo_color = definition.visual_color - body_material.roughness = 0.82 - body_mesh.material_override = body_material - left_arm.material_override = body_material - right_arm.material_override = body_material + AnimalAppearance.set_profession_color(self, definition.visual_color) var prop_material := StandardMaterial3D.new() prop_material.albedo_color = definition.prop_color diff --git a/player/npc/NpcVisual.tscn b/player/npc/NpcVisual.tscn index bf710a0..6129c25 100644 --- a/player/npc/NpcVisual.tscn +++ b/player/npc/NpcVisual.tscn @@ -1,70 +1,9 @@ -[gd_scene load_steps=24 format=3 uid="uid://dhxxyprqflotq"] +[gd_scene load_steps=13 format=3 uid="uid://dhxxyprqflotq"] [ext_resource type="Script" uid="uid://bha8uf40p3sa0" path="res://player/npc/NpcVisual.gd" id="1_ixfoq"] [sub_resource type="CapsuleShape3D" id="CapsuleShape3D_d844k"] -[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_d844k"] -albedo_color = Color(0.46, 0.58, 0.33, 1) -roughness = 0.82 - -[sub_resource type="CylinderMesh" id="CylinderMesh_body"] -material = SubResource("StandardMaterial3D_d844k") -top_radius = 0.28 -bottom_radius = 0.42 -height = 0.9 - -[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_skin"] -albedo_color = Color(0.84, 0.61, 0.39, 1) -roughness = 0.88 - -[sub_resource type="SphereMesh" id="SphereMesh_head"] -material = SubResource("StandardMaterial3D_skin") -radius = 0.29 -height = 0.56 - -[sub_resource type="CylinderMesh" id="CylinderMesh_hair"] -top_radius = 0.24 -bottom_radius = 0.29 -height = 0.18 - -[sub_resource type="CylinderMesh" id="CylinderMesh_arm"] -material = SubResource("StandardMaterial3D_d844k") -top_radius = 0.095 -bottom_radius = 0.11 -height = 0.58 - -[sub_resource type="SphereMesh" id="SphereMesh_hand"] -material = SubResource("StandardMaterial3D_skin") -radius = 0.12 -height = 0.22 - -[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_pants"] -albedo_color = Color(0.16, 0.22, 0.25, 1) -roughness = 0.9 - -[sub_resource type="CapsuleMesh" id="CapsuleMesh_leg"] -material = SubResource("StandardMaterial3D_pants") -radius = 0.115 -height = 0.62 - -[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_boot"] -albedo_color = Color(0.13, 0.075, 0.04, 1) -roughness = 0.95 - -[sub_resource type="BoxMesh" id="BoxMesh_foot"] -material = SubResource("StandardMaterial3D_boot") -size = Vector3(0.23, 0.15, 0.36) - -[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_eye"] -albedo_color = Color(0.06, 0.045, 0.035, 1) -roughness = 0.8 - -[sub_resource type="SphereMesh" id="SphereMesh_eye"] -material = SubResource("StandardMaterial3D_eye") -radius = 0.035 -height = 0.06 - [sub_resource type="StandardMaterial3D" id="StandardMaterial3D_food"] albedo_color = Color(0.38, 0.22, 0.08, 1) roughness = 0.9 @@ -139,62 +78,29 @@ shape = SubResource("CapsuleShape3D_d844k") [node name="MeshInstance3D" type="MeshInstance3D" parent="."] position = Vector3(0, 1.05, 0) -mesh = SubResource("CylinderMesh_body") [node name="Head" type="MeshInstance3D" parent="."] position = Vector3(0, 1.68, 0) -mesh = SubResource("SphereMesh_head") [node name="Hair" type="MeshInstance3D" parent="."] position = Vector3(0, 1.9, -0.015) -mesh = SubResource("CylinderMesh_hair") - -[node name="EyeLeft" type="MeshInstance3D" parent="."] -position = Vector3(-0.095, 1.72, 0.274) -mesh = SubResource("SphereMesh_eye") - -[node name="EyeRight" type="MeshInstance3D" parent="."] -position = Vector3(0.095, 1.72, 0.274) -mesh = SubResource("SphereMesh_eye") [node name="ArmLeft" type="MeshInstance3D" parent="."] -position = Vector3(-0.39, 1.08, 0) -rotation = Vector3(0, 0, -0.139626) -mesh = SubResource("CylinderMesh_arm") - -[node name="Hand" type="MeshInstance3D" parent="ArmLeft"] -position = Vector3(0, -0.35, 0) -mesh = SubResource("SphereMesh_hand") +position = Vector3(-0.39, 1.3, 0) [node name="ArmRight" type="MeshInstance3D" parent="."] -position = Vector3(0.39, 1.08, 0) -rotation = Vector3(0, 0, 0.139626) -mesh = SubResource("CylinderMesh_arm") - -[node name="Hand" type="MeshInstance3D" parent="ArmRight"] -position = Vector3(0, -0.35, 0) -mesh = SubResource("SphereMesh_hand") +position = Vector3(0.39, 1.3, 0) [node name="LegLeft" type="MeshInstance3D" parent="."] -position = Vector3(-0.18, 0.43, 0) -mesh = SubResource("CapsuleMesh_leg") - -[node name="Foot" type="MeshInstance3D" parent="LegLeft"] -position = Vector3(0, -0.28, 0.09) -mesh = SubResource("BoxMesh_foot") +position = Vector3(-0.18, 0.61, 0) [node name="LegRight" type="MeshInstance3D" parent="."] -position = Vector3(0.18, 0.43, 0) -mesh = SubResource("CapsuleMesh_leg") - -[node name="Foot" type="MeshInstance3D" parent="LegRight"] -position = Vector3(0, -0.28, 0.09) -mesh = SubResource("BoxMesh_foot") +position = Vector3(0.18, 0.61, 0) [node name="ProfessionProp" type="MeshInstance3D" parent="."] [node name="ProfessionLabel" type="Label3D" parent="."] -position = Vector3(0, 2.05, 0) +position = Vector3(0, 2.8, 0) billboard = 1 no_depth_test = true text = "Villager" @@ -223,13 +129,13 @@ mesh = SubResource("CylinderMesh_wood") [node name="TaskGlyphRoot" type="Node3D" parent="."] visible = false -position = Vector3(0, 2.35, 0) +position = Vector3(0, 2.9, 0) [node name="TaskGlyph" type="MeshInstance3D" parent="TaskGlyphRoot"] [node name="OpportunityConcernRoot" type="Node3D" parent="."] visible = false -position = Vector3(-0.4, 2.72, 0) +position = Vector3(-0.4, 3.25, 0) [node name="EmptyBowl" type="MeshInstance3D" parent="OpportunityConcernRoot"] rotation_degrees = Vector3(72, 0, 0) @@ -250,7 +156,7 @@ mesh = SubResource("SphereMesh_concern") [node name="TrustReactionRoot" type="Node3D" parent="."] visible = false -position = Vector3(0, 2.72, 0) +position = Vector3(0, 3.25, 0) scale = Vector3(0.24, 0.24, 0.24) [node name="Halo" type="MeshInstance3D" parent="TrustReactionRoot"] @@ -278,3 +184,6 @@ path_desired_distance = 0.3 target_desired_distance = 0.5 height = 1.7 radius = 0.4 + +[node name="Tail" type="MeshInstance3D" parent="."] +position = Vector3(0, 0.8, -0.22) diff --git a/player/player_visual.gd b/player/player_visual.gd index 49a2137..4720417 100644 --- a/player/player_visual.gd +++ b/player/player_visual.gd @@ -14,6 +14,7 @@ var walk_phase := 0.0 func _ready() -> void: player_body = get_parent() as CharacterBody3D + AnimalAppearance.apply_to(self, 0) func _process(delta: float) -> void: diff --git a/tests/jajce_runtime_integration_test.gd b/tests/jajce_runtime_integration_test.gd index fdfd949..4bf4c5d 100644 --- a/tests/jajce_runtime_integration_test.gd +++ b/tests/jajce_runtime_integration_test.gd @@ -535,7 +535,7 @@ func _run() -> void: ( main_scene.has_node("Player/Visual/Body") and main_scene.has_node("Player/Visual/Head") - and main_scene.has_node("Player/Visual/Scarf") + and main_scene.get_node("Player/Visual").get_meta("animal_species") == &"fox" ), "Player should use the same readable multi-part visual language as villagers" ) diff --git a/tests/jajce_world_scaffold_test.gd b/tests/jajce_world_scaffold_test.gd index 7afae4a..a18b2a3 100644 --- a/tests/jajce_world_scaffold_test.gd +++ b/tests/jajce_world_scaffold_test.gd @@ -89,6 +89,11 @@ func _run() -> void: "Village center should include a few oversized ambient butterflies" ) var grass_field := world.get_node("TerrainRoot/Terrain3D/CozyGrassField") + var grass_mesh := grass_field.get("mesh") as ArrayMesh + _check( + grass_mesh != null and grass_mesh.get_faces().size() == 105, + "Meadow grass must reuse the 35-triangle Blender tuft" + ) var grass_material := grass_field.get("mesh_material_override") as ShaderMaterial var grass_process := grass_field.get("process_material") as ShaderMaterial _check( @@ -112,6 +117,10 @@ func _run() -> void: "Grass placement should stay short enough to preserve villagers and paths" ) var grass_controller := world.get_node("TerrainRoot/GrassInteractionController") + _check( + grass_material.get_shader_parameter("clearing_count") == 4, + "Grass must leave the four authored walking paths clear" + ) _check( grass_controller.get("grass_material") == grass_material, "Grass interaction should update the same material rendered by Terrain3D particles" diff --git a/tests/npc_visual_lifecycle_test.gd b/tests/npc_visual_lifecycle_test.gd index d012171..380b1fe 100644 --- a/tests/npc_visual_lifecycle_test.gd +++ b/tests/npc_visual_lifecycle_test.gd @@ -26,17 +26,28 @@ func _run() -> void: var trust_reaction_root: Node3D = visual.get_node("TrustReactionRoot") _check(not trust_reaction_root.visible, "A newly spawned visual should not replay reactions") _check( - body.material_override.albedo_color == profession_definition.visual_color, - "NPC body color should come from its profession definition" + ( + body.material_override is ShaderMaterial + and ( + body.material_override.get_shader_parameter("coat_color") + == profession_definition.visual_color + ) + ), + "NPC cel material should retain its authoritative profession color" ) _check( ( - body.mesh is CylinderMesh + body.mesh is ArrayMesh and visual.has_node("Head") and visual.has_node("ArmLeft") - and visual.has_node("LegRight/Foot") + and visual.get_node("Tail").mesh is ArrayMesh ), - "NPC presentation should use a readable multi-part villager silhouette" + "NPC presentation should use the Blender animal body and tail" + ) + var original_species: StringName = visual.get_meta("animal_species") + _check( + visual.get_node("TaskGlyphRoot").position.y >= 2.9, + "Task cues must sit above the animal cast's tall ears" ) _check( ( @@ -184,6 +195,10 @@ func _run() -> void: _check(view.reload_npc_visual(npc.id), "Visual should reload") var reloaded: Node3D = view.active_npc_visuals[npc.id] reloaded.set_physics_process(false) + _check( + reloaded.get_meta("animal_species") == original_species, + "Reload must derive the same animal identity without saved appearance or RNG" + ) _check( reloaded.global_position.is_equal_approx(authoritative_position), "Reloaded visual should use authoritative position" diff --git a/tools/art/.gdignore b/tools/art/.gdignore new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tools/art/.gdignore @@ -0,0 +1 @@ + diff --git a/tools/art/build_storybook_assets.py b/tools/art/build_storybook_assets.py new file mode 100644 index 0000000..da6b673 --- /dev/null +++ b/tools/art/build_storybook_assets.py @@ -0,0 +1,280 @@ +"""Rebuild the original woodland cast and meadow tuft with Blender 5.1+. + +Blender --background --python tools/art/build_storybook_assets.py +Editable .blend sources are outside Godot's import tree. Runtime GLBs contain +vertex colors, one material and eight rigid animation parts per character. +Coordinates below use Godot's Y-up, +Z-facing convention; export converts it. +""" + +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/storybook" +OUTPUT = ROOT / "assets/storybook" +OUTPUT.mkdir(parents=True, exist_ok=True) +SOURCE.mkdir(parents=True, exist_ok=True) + +INK = "303733" +CREAM = "f4e5bf" +PINK = "c98678" +LEATHER = "654933" +GOLD = "d9aa54" +PIVOTS = { + "Body": (0, 1.05, 0), "Head": (0, 1.68, 0), "Hair": (0, 1.9, 0), + "ArmLeft": (-0.39, 1.30, 0), "ArmRight": (0.39, 1.30, 0), + "LegLeft": (-0.18, 0.61, 0), "LegRight": (0.18, 0.61, 0), + "Tail": (0, 0.8, -0.22), +} +PARTS = {} +STATS = {} + + +def coord(v): + return Vector((v[0], -v[2], v[1])) + + +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 paint(obj, color, part, cloth=False): + attr = obj.data.color_attributes.new(name="Color", type="FLOAT_COLOR", domain="CORNER") + rgb = linear(color) + for entry in attr.data: + entry.color = (*rgb, 1.0 if cloth else 0.0) + obj.data.materials.append(bpy.data.materials["StorybookPalette"]) + for poly in obj.data.polygons: + poly.use_smooth = True + PARTS.setdefault(part, []).append(obj) + return obj + + +def ellipsoid(part, center, radius, color, cloth=False, tilt=0, segments=16, rings=10): + bpy.ops.mesh.primitive_uv_sphere_add(segments=segments, ring_count=rings, location=coord(center)) + obj = bpy.context.object + obj.scale = (radius[0], radius[2], radius[1]) + obj.rotation_euler.y = math.radians(tilt) + return paint(obj, color, part, cloth) + + +def tube(part, points, radius, color, cloth=False, sides=8): + verts, faces = [], [] + for i, point in enumerate(points): + p = coord(point) + tangent = coord(points[min(i + 1, len(points) - 1)]) - coord(points[max(0, i - 1)]) + tangent.normalize() + side = tangent.cross(Vector((0, 1, 0))) + if side.length < 0.01: + side = tangent.cross(Vector((1, 0, 0))) + side.normalize() + other = tangent.cross(side).normalized() + r = radius[i] if isinstance(radius, list) else radius + for j in range(sides): + a = math.tau * j / sides + verts.append(p + r * (math.cos(a) * side + math.sin(a) * other)) + if i: + for j in range(sides): + a, b = (i - 1) * sides + j, (i - 1) * sides + (j + 1) % sides + faces.append((a, b, b + sides, a + sides)) + faces.extend([tuple(reversed(range(sides))), tuple(range(len(verts) - sides, len(verts)))]) + mesh = bpy.data.meshes.new(part + "Detail") + mesh.from_pydata(verts, [], faces) + mesh.update() + obj = bpy.data.objects.new(part + "Detail", mesh) + bpy.context.collection.objects.link(obj) + return paint(obj, color, part, cloth) + + +def leaf_ear(part, x, bottom, height, width, fur, tilt): + ellipsoid(part, (x, bottom + height * 0.46, -0.025), (width, height * 0.55, width * 0.57), fur, tilt=tilt) + ellipsoid(part, (x, bottom + height * 0.48, width * 0.48), (width * 0.58, height * 0.39, 0.024), PINK, tilt=tilt, segments=12, rings=8) + + +def reset(): + bpy.ops.object.select_all(action="SELECT") + bpy.ops.object.delete(use_global=False) + PARTS.clear() + material = bpy.data.materials.get("StorybookPalette") or bpy.data.materials.new("StorybookPalette") + material.use_nodes = True + tree = material.node_tree + tree.nodes.clear() + vertex = tree.nodes.new("ShaderNodeVertexColor") + vertex.layer_name = "Color" + shader = tree.nodes.new("ShaderNodeBsdfPrincipled") + shader.inputs["Roughness"].default_value = 0.88 + tree.links.new(vertex.outputs["Color"], shader.inputs["Base Color"]) + output = tree.nodes.new("ShaderNodeOutputMaterial") + tree.links.new(shader.outputs["BSDF"], output.inputs["Surface"]) + + +def finish(name): + objects = [] + for part, pieces in PARTS.items(): + bpy.ops.object.select_all(action="DESELECT") + for obj in pieces: + obj.select_set(True) + bpy.context.view_layer.objects.active = pieces[0] + bpy.ops.object.join() + obj = bpy.context.object + obj.name = part + bpy.ops.object.transform_apply(location=False, rotation=True, scale=True) + bpy.context.scene.cursor.location = coord(PIVOTS.get(part, (0, 0, 0))) + bpy.ops.object.origin_set(type="ORIGIN_CURSOR") + # Join left duplicate palette slots; one surface per animated part. + obj.data.materials.clear() + obj.data.materials.append(bpy.data.materials["StorybookPalette"]) + for polygon in obj.data.polygons: + polygon.material_index = 0 + obj.data.calc_loop_triangles() + objects.append(obj) + bpy.ops.object.select_all(action="SELECT") + 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, + ) + bpy.context.scene.cursor.location = (0, 0, 0) + # A useful material-colored editing view on reopening the source. + 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"))) + STATS[name] = {"triangles": sum(len(o.data.loop_triangles) for o in objects), + "surfaces": len(objects), "bytes": (OUTPUT / (name + ".glb")).stat().st_size} + + +def character(species, fur, coat, scarf): + reset() + stout = 1.13 if species == "badger" else (0.90 if species == "rabbit" else 1.0) + # Pear-shaped coat, soft collar, pockets, rolled hems and hand-sewn buttons. + ellipsoid("Body", (0, 0.98, 0), (0.40 * stout, 0.50, 0.29), coat, True) + ellipsoid("Body", (0, 1.25, 0), (0.30 * stout, 0.28, 0.23), coat, True) + tube("Body", [(-0.22, 1.40, 0.14), (0, 1.34, 0.245), (0.23, 1.40, 0.13)], 0.065, scarf) + ellipsoid("Body", (0.17, 1.23, 0.287), (0.085, 0.22, 0.025), scarf, tilt=-12) + for y in (0.83, 1.0, 1.17): + ellipsoid("Body", (-0.045, y, 0.292), (0.032, 0.032, 0.022), GOLD, segments=8, rings=6) + for x in (-0.235, 0.235): + ellipsoid("Body", (x, 0.83, 0.243), (0.108, 0.10, 0.022), coat, True, tilt=x * 28, segments=12, rings=8) + tube("Body", [(x - 0.07, 0.895, 0.266), (x + 0.07, 0.895, 0.266)], 0.012, GOLD, sides=5) + # Leather sling and tiny clasp. The inventory props remain state-controlled. + tube("Body", [(-0.26, 1.39, 0.18), (-0.06, 1.16, 0.302), (0.21, 0.88, 0.29), (0.34, 0.73, 0.07)], 0.027, LEATHER) + ellipsoid("Body", (0.37, 0.74, 0.04), (0.15, 0.18, 0.12), LEATHER) + ellipsoid("Body", (0.39, 0.80, 0.15), (0.04, 0.035, 0.015), GOLD, segments=8, rings=6) + for side, x in (("Left", -0.39), ("Right", 0.39)): + ellipsoid("Arm" + side, (x, 1.10, 0), (0.145, 0.31, 0.145), coat, True, tilt=-x * 10) + ellipsoid("Arm" + side, (x, 0.82, 0.018), (0.145, 0.065, 0.148), scarf) + ellipsoid("Arm" + side, (x, 0.73, 0.024), (0.123, 0.115, 0.12), fur) + ellipsoid("Arm" + side, (x * 0.78, 0.745, 0.075), (0.055, 0.066, 0.05), fur, segments=10, rings=6) + for side, x in (("Left", -0.18), ("Right", 0.18)): + ellipsoid("Leg" + side, (x, 0.41, 0), (0.137, 0.28, 0.135), "52605a") + ellipsoid("Leg" + side, (x, 0.14, 0.075), (0.16, 0.13, 0.225), LEATHER) + tube("Leg" + side, [(x - 0.09, 0.22, 0.19), (x + 0.09, 0.22, 0.19)], 0.014, GOLD, sides=5) + # A broad face, cheek planes and projecting muzzle, not a capsule head. + head_w = 0.37 * stout + ellipsoid("Head", (0, 1.76, 0), (head_w, 0.34, 0.29), fur) + if species == "fox": + for s in (-1, 1): + tube("Head", [(s * 0.23, 1.99, 0), (s * 0.29, 2.19, -0.03), (s * 0.33, 2.39, -0.055)], [0.135, 0.095, 0.005], fur, sides=8) + tube("Head", [(s * 0.24, 2.05, 0.105), (s * 0.30, 2.25, 0.02)], [0.06, 0.006], PINK, sides=6) + ellipsoid("Head", (s * 0.19, 1.63, 0.21), (0.19, 0.15, 0.16), CREAM, tilt=s * 18) + ellipsoid("Head", (0, 1.69, 0.29), (0.16, 0.105, 0.22), CREAM) + tube("Tail", [(0, 0.81, -0.2), (0.17, 0.52, -0.49), (0.44, 0.54, -0.76), (0.56, 0.82, -0.76)], [0.12, 0.23, 0.24, 0.17], fur, sides=12) + tube("Tail", [(0.53, 0.74, -0.78), (0.57, 0.95, -0.72), (0.48, 1.12, -0.60)], [0.195, 0.15, 0.003], CREAM, sides=12) + elif species == "rabbit": + leaf_ear("Head", -0.19, 1.98, 0.70, 0.10, fur, -12) + leaf_ear("Head", 0.21, 1.96, 0.62, 0.12, fur, 22) + ellipsoid("Tail", (0, 0.74, -0.36), (0.19, 0.18, 0.19), CREAM) + elif species == "badger": + for s in (-1, 1): + ellipsoid("Head", (s * 0.30, 2.01, -0.02), (0.125, 0.145, 0.09), INK) + ellipsoid("Head", (s * 0.30, 2.025, 0.053), (0.07, 0.085, 0.025), CREAM) + ellipsoid("Head", (s * 0.19, 1.82, 0.226), (0.10, 0.25, 0.093), INK, tilt=s * -18) + ellipsoid("Head", (0, 1.68, 0.32), (0.22, 0.14, 0.16), CREAM) + ellipsoid("Tail", (0, 0.67, -0.34), (0.13, 0.25, 0.16), fur) + else: + for s in (-1, 1): + ellipsoid("Head", (s * 0.31, 1.94, 0), (0.10, 0.115, 0.075), fur) + ellipsoid("Head", (s * 0.32, 1.94, 0.063), (0.054, 0.064, 0.025), PINK) + tube("Tail", [(0, 0.80, -0.22), (0, 0.46, -0.52), (0.18, 0.22, -0.78), (0.37, 0.19, -0.81)], [0.16, 0.15, 0.09, 0.008], fur, sides=10) + if species != "fox": + for s in (-1, 1): + ellipsoid("Head", (s * 0.09, 1.64, 0.282), (0.15, 0.10, 0.115), CREAM) + nose_z = 0.48 if species == "fox" else 0.408 + ellipsoid("Head", (0, 1.706, nose_z), (0.058, 0.043, 0.047), PINK if species == "rabbit" else INK, segments=12, rings=8) + for s in (-1, 1): + eye_x = s * (0.16 if species != "badger" else 0.20) + eye_z = 0.33 if species == "badger" else 0.26 + ellipsoid("Head", (eye_x, 1.845, eye_z), (0.049, 0.061, 0.037), INK) + ellipsoid("Head", (eye_x - 0.012, 1.868, eye_z + 0.03), (0.013, 0.018, 0.012), CREAM, segments=8, rings=6) + tube("Head", [(eye_x - 0.047, 1.954, eye_z), (eye_x, 1.97 + s * 0.012, eye_z + 0.01), (eye_x + 0.042, 1.95, eye_z)], 0.017, CREAM if species == "badger" else INK, sides=6) + ellipsoid("Head", (s * 0.26, 1.688, 0.248), (0.046, 0.024, 0.025), PINK, segments=10, rings=6) + tube("Head", [(0, 1.659, nose_z - 0.008), (s * 0.06, 1.611, nose_z - 0.045), (s * 0.117, 1.636, nose_z - 0.085)], 0.009, INK, sides=5) + # Accessories give each silhouette a small joke and a practical occupation. + if species == "badger": + for s in (-1, 1): + points = [(s * 0.20 + math.cos(a * math.tau / 20) * 0.09, 1.845 + math.sin(a * math.tau / 20) * 0.08, 0.373) for a in range(21)] + tube("Head", points, 0.009, GOLD, sides=5) + tube("Head", [(-0.11, 1.85, 0.373), (0, 1.88, 0.39), (0.11, 1.85, 0.373)], 0.011, GOLD, sides=5) + if species in ("otter", "rabbit"): + hat_color = "d3b66f" if species == "rabbit" else "777d52" + ellipsoid("Hair", (0, 2.025, -0.01), (0.44, 0.055, 0.34), hat_color) + ellipsoid("Hair", (0, 2.09, -0.035), (0.28, 0.14, 0.245), hat_color) + tube("Hair", [(-0.25, 2.063, 0.09), (0, 2.06, 0.23), (0.24, 2.063, 0.09)], 0.026, scarf) + ellipsoid("Hair", (0.25, 2.15, 0.15), (0.038, 0.12, 0.065), "8f9c5a", tilt=40, segments=10, rings=6) + else: + # A little forelock above the brow, merged into the existing animated slot. + ellipsoid("Hair", (-0.04, 2.04, 0.05), (0.11, 0.07, 0.15), fur, tilt=-25, segments=12, rings=8) + finish(species) + + +def grass(): + reset() + rng = random.Random(7103) + verts, faces, uv = [], [], [] + for blade in range(7): + angle = blade * 2.399 + center = Vector((math.cos(angle) * 0.15, math.sin(angle) * 0.15, 0)) + width = rng.uniform(0.025, 0.055) + height = rng.uniform(0.8, 1.0) + sideways = Vector((math.cos(angle), math.sin(angle), 0)) + bend = Vector((math.cos(angle + 0.8), math.sin(angle + 0.8), 0)) * rng.uniform(0.045, 0.12) + start = len(verts) + for level in (0, 0.42, 0.77): + for side in (-1, 1): + verts.append(center + Vector((0, 0, height * level)) + bend * level * level + sideways * side * width * (1 - level)) + uv.append(((side + 1) / 2, level)) + verts.append(center + Vector((0, 0, height)) + bend) + uv.append((0.5, 1)) + for level in range(2): + a = start + level * 2 + faces.extend([(a, a + 1, a + 2), (a + 1, a + 3, a + 2)]) + faces.append((start + 4, start + 5, start + 6)) + mesh = bpy.data.meshes.new("MeadowTuft") + mesh.from_pydata(verts, [], faces) + mesh.update() + layer = mesh.uv_layers.new(name="UVMap") + for loop in mesh.loops: + layer.data[loop.index].uv = uv[loop.vertex_index] + obj = bpy.data.objects.new("Grass", mesh) + bpy.context.collection.objects.link(obj) + paint(obj, "ffffff", "Grass") + finish("meadow_grass") + + +character("fox", "c17b42", "568b82", "d6934d") +character("rabbit", "d4c5a5", "839d70", "c68061") +character("badger", "b8b9a8", "7c879c", "c5aa6d") +character("otter", "947055", "b77c58", "8caa98") +grass() +(OUTPUT / "mesh_budget.json").write_text(json.dumps(STATS, indent=2) + "\n") +print("STORYBOOK ASSET BUDGET", json.dumps(STATS)) diff --git a/tools/capture_storybook.gd b/tools/capture_storybook.gd new file mode 100644 index 0000000..d6e212b --- /dev/null +++ b/tools/capture_storybook.gd @@ -0,0 +1,171 @@ +extends SceneTree +## Reproducible native-renderer character and runtime camera review. + +const OUTPUT := "res://docs/baselines/" +const CAPTURE_SIZE := Vector2i(1600, 900) + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + root.size = CAPTURE_SIZE + if OS.get_cmdline_user_args().has("--cast"): + await _capture_cast() + else: + await _capture_world() + quit(0) + + +func _capture_world() -> void: + var main := load("res://main.tscn").instantiate() as Node3D + main.get_node("SimulationManager").set("debug_logs", false) + root.add_child(main) + await process_frame + var manager := main.get_node("SimulationManager") + manager.set_process(false) + var cycle := main.get_node("JajceWorld/DayNightCycle") + cycle.set_process(false) + cycle.call("_apply_light_rotation", 0.40) + cycle.call("_apply_environment", 0.40) + 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() + var rig := main.get_node("CameraRig") as Node3D + rig.set_physics_process(false) + rig.set_process_unhandled_input(false) + var player := main.get_node("Player") as CharacterBody3D + for _frame in 30: + await process_frame + player.set_physics_process(false) + var camera := root.get_camera_3d() + camera.reparent(main) + camera.global_position = player.global_position + Vector3(7.5, 5.5, 10.5) + camera.look_at(player.global_position + Vector3(0, 1.1, 0)) + camera.fov = 48.0 + for _frame in 90: + await process_frame + await _save("storybook_gameplay.png") + camera.global_position = Vector3(38, 24, 44) + camera.look_at(Vector3(-4, 3, 0)) + camera.fov = 53.0 + for _frame in 60: + await process_frame + await _save("storybook_valley.png") + var world := main.get_node("JajceWorld") as JajceWorld + var samples: Array[Dictionary] = [] + for quality in [ + JajceWorld.PresentationQuality.HIGH, + JajceWorld.PresentationQuality.BALANCED, + JajceWorld.PresentationQuality.LOW + ]: + world.apply_presentation_quality(quality) + for _frame in 60: + 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() + ( + samples + . append( + { + "profile": world.get_active_presentation_quality_name(), + "frame_wall_p50_ms": frame_times[60], + "frame_wall_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), + "objects": Performance.get_monitor(Performance.RENDER_TOTAL_OBJECTS_IN_FRAME), + "active_npcs": main.get_node("ActiveNPCs").get_child_count(), + } + ) + ) + var file := FileAccess.open(OUTPUT + "storybook_render_metrics.json", FileAccess.WRITE) + ( + file + . store_string( + ( + ( + JSON + . stringify( + { + "renderer": RenderingServer.get_current_rendering_driver_name(), + "resolution": "1600x900", + "workload": + "Paused static valley; wall-frame latency includes vsync, not isolated GPU time", + "samples": samples + }, + "\t" + ) + ) + + "\n" + ) + ) + ) + + +func _capture_cast() -> void: + var stage := Node3D.new() + root.add_child(stage) + var environment := WorldEnvironment.new() + environment.environment = Environment.new() + var env := environment.environment + env.background_mode = Environment.BG_COLOR + env.background_color = Color("d8e3d5") + env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR + env.ambient_light_color = Color("bbd2d3") + env.ambient_light_energy = 0.65 + env.tonemap_mode = Environment.TONE_MAPPER_FILMIC + stage.add_child(environment) + var light := DirectionalLight3D.new() + light.rotation_degrees = Vector3(-42, -32, 0) + light.light_color = Color("fff0cd") + light.light_energy = 1.3 + light.shadow_enabled = true + stage.add_child(light) + var floor_mesh := MeshInstance3D.new() + var plane := PlaneMesh.new() + plane.size = Vector2(200, 200) + var material := StandardMaterial3D.new() + material.albedo_color = Color("bdcbb5") + material.roughness = 1.0 + plane.material = material + floor_mesh.mesh = plane + stage.add_child(floor_mesh) + for index in 4: + var actor := load("res://player/PlayerVisual.tscn").instantiate() as Node3D + stage.add_child(actor) + actor.set_process(false) + AnimalAppearance.apply_to(actor, index) + actor.get_node("Sword").hide() + actor.position = Vector3((float(index) - 1.5) * 2.0, 0, 0) + actor.rotation_degrees.y = -12.0 + float(index) * 5.0 + var camera := Camera3D.new() + stage.add_child(camera) + camera.position = Vector3(2.2, 3.1, 10.5) + camera.look_at(Vector3(0, 1.25, 0)) + camera.projection = Camera3D.PROJECTION_ORTHOGONAL + camera.size = 6.2 + camera.current = true + for _frame in 60: + await process_frame + await _save("storybook_cast.png") + stage.queue_free() + await process_frame + + +func _save(filename: String) -> void: + await RenderingServer.frame_post_draw + var capture := root.get_texture().get_image() + assert(not capture.is_empty()) + var error := capture.save_png(OUTPUT + filename) + assert(error == OK) + print("[TOOL] Saved " + OUTPUT + filename) diff --git a/tools/capture_storybook.gd.uid b/tools/capture_storybook.gd.uid new file mode 100644 index 0000000..fc312ba --- /dev/null +++ b/tools/capture_storybook.gd.uid @@ -0,0 +1 @@ +uid://bqcj3uman431w diff --git a/tools/import_storybook_grass.gd b/tools/import_storybook_grass.gd new file mode 100644 index 0000000..bf79e86 --- /dev/null +++ b/tools/import_storybook_grass.gd @@ -0,0 +1,12 @@ +extends SceneTree +## Run after Blender export and Godot import; the particle field requires a Mesh. + + +func _initialize() -> void: + var scene := load("res://assets/storybook/meadow_grass.glb") as PackedScene + var instance := scene.instantiate() + var grass := instance.find_child("Grass", true, false) as MeshInstance3D + assert(grass != null) + var error := ResourceSaver.save(grass.mesh, "res://assets/storybook/meadow_grass.res") + instance.free() + quit(0 if error == OK else 1) diff --git a/tools/import_storybook_grass.gd.uid b/tools/import_storybook_grass.gd.uid new file mode 100644 index 0000000..f54b781 --- /dev/null +++ b/tools/import_storybook_grass.gd.uid @@ -0,0 +1 @@ +uid://blkknmqjyo6uj diff --git a/world/jajce/BridgeBlockout.tscn b/world/jajce/BridgeBlockout.tscn index 17b2575..6c40966 100644 --- a/world/jajce/BridgeBlockout.tscn +++ b/world/jajce/BridgeBlockout.tscn @@ -1,16 +1,18 @@ -[gd_scene load_steps=9 format=3] +[gd_scene load_steps=10 format=3] -[sub_resource type="StandardMaterial3D" id="Material_deck"] -albedo_color = Color(0.61, 0.53, 0.42, 1) -roughness = 0.94 +[ext_resource type="Shader" path="res://world/presentation/materials/storybook_surface.gdshader" id="story_surface"] + +[sub_resource type="ShaderMaterial" id="Material_deck"] +shader = ExtResource("story_surface") +shader_parameter/albedo = Color(0.61, 0.53, 0.42, 1) [sub_resource type="BoxMesh" id="Mesh_deck"] material = SubResource("Material_deck") size = Vector3(15.2, 0.32, 3.15) -[sub_resource type="StandardMaterial3D" id="Material_stone"] -albedo_color = Color(0.48, 0.47, 0.41, 1) -roughness = 0.98 +[sub_resource type="ShaderMaterial" id="Material_stone"] +shader = ExtResource("story_surface") +shader_parameter/albedo = Color(0.48, 0.47, 0.41, 1) [sub_resource type="BoxMesh" id="Mesh_abutment"] material = SubResource("Material_stone") @@ -20,9 +22,9 @@ size = Vector3(2.25, 1.45, 4.2) material = SubResource("Material_stone") size = Vector3(1.05, 1.65, 3.7) -[sub_resource type="StandardMaterial3D" id="Material_rail"] -albedo_color = Color(0.2, 0.115, 0.055, 1) -roughness = 0.92 +[sub_resource type="ShaderMaterial" id="Material_rail"] +shader = ExtResource("story_surface") +shader_parameter/albedo = Color(0.2, 0.115, 0.055, 1) [sub_resource type="BoxMesh" id="Mesh_rail"] material = SubResource("Material_rail") diff --git a/world/jajce/FortressBlockout.tscn b/world/jajce/FortressBlockout.tscn index c03cc4c..e7c3288 100644 --- a/world/jajce/FortressBlockout.tscn +++ b/world/jajce/FortressBlockout.tscn @@ -1,8 +1,10 @@ -[gd_scene load_steps=19 format=3] +[gd_scene load_steps=21 format=3] -[sub_resource type="StandardMaterial3D" id="Material_rock"] -albedo_color = Color(0.25, 0.27, 0.24, 1) -roughness = 1.0 +[ext_resource type="Shader" path="res://world/presentation/materials/storybook_surface.gdshader" id="story_surface"] + +[sub_resource type="ShaderMaterial" id="Material_rock"] +shader = ExtResource("story_surface") +shader_parameter/albedo = Color(0.25, 0.27, 0.24, 1) [sub_resource type="CylinderMesh" id="Mesh_rock_plinth"] material = SubResource("Material_rock") @@ -11,13 +13,13 @@ bottom_radius = 10.2 height = 3.2 radial_segments = 9 -[sub_resource type="StandardMaterial3D" id="Material_stone"] -albedo_color = Color(0.47, 0.47, 0.43, 1) -roughness = 0.96 +[sub_resource type="ShaderMaterial" id="Material_stone"] +shader = ExtResource("story_surface") +shader_parameter/albedo = Color(0.47, 0.47, 0.43, 1) -[sub_resource type="StandardMaterial3D" id="Material_stone_light"] -albedo_color = Color(0.57, 0.56, 0.51, 1) -roughness = 0.95 +[sub_resource type="ShaderMaterial" id="Material_stone_light"] +shader = ExtResource("story_surface") +shader_parameter/albedo = Color(0.57, 0.56, 0.51, 1) [sub_resource type="BoxMesh" id="Mesh_keep"] material = SubResource("Material_stone_light") @@ -57,9 +59,9 @@ size = Vector3(1.25, 3.7, 1.7) material = SubResource("Material_stone_light") size = Vector3(4.6, 1.05, 1.7) -[sub_resource type="StandardMaterial3D" id="Material_gate"] -albedo_color = Color(0.18, 0.11, 0.065, 1) -roughness = 0.9 +[sub_resource type="ShaderMaterial" id="Material_gate"] +shader = ExtResource("story_surface") +shader_parameter/albedo = Color(0.18, 0.11, 0.065, 1) [sub_resource type="BoxMesh" id="Mesh_gate"] material = SubResource("Material_gate") @@ -72,17 +74,17 @@ bottom_radius = 0.09 height = 4.2 radial_segments = 8 -[sub_resource type="StandardMaterial3D" id="Material_banner"] -albedo_color = Color(0.58, 0.1, 0.08, 1) -roughness = 0.76 +[sub_resource type="ShaderMaterial" id="Material_banner"] +shader = ExtResource("story_surface") +shader_parameter/albedo = Color(0.58, 0.1, 0.08, 1) [sub_resource type="BoxMesh" id="Mesh_banner"] material = SubResource("Material_banner") size = Vector3(0.12, 2.2, 1.35) -[sub_resource type="StandardMaterial3D" id="Material_window_slit"] -albedo_color = Color(0.12, 0.14, 0.13, 1) -roughness = 0.98 +[sub_resource type="ShaderMaterial" id="Material_window_slit"] +shader = ExtResource("story_surface") +shader_parameter/albedo = Color(0.12, 0.14, 0.13, 1) [sub_resource type="BoxMesh" id="Mesh_window_slit"] material = SubResource("Material_window_slit") diff --git a/world/jajce/GrassInteractionController.gd b/world/jajce/GrassInteractionController.gd index 6ee53d8..ef5b1b6 100644 --- a/world/jajce/GrassInteractionController.gd +++ b/world/jajce/GrassInteractionController.gd @@ -1,18 +1,45 @@ extends Node const SHADER_INTERACTOR_LIMIT := 8 +const CLEARING_LIMIT := 4 @export var grass_material: ShaderMaterial @export_range(1, SHADER_INTERACTOR_LIMIT, 1) var max_interactors := 8 @export_range(0.02, 0.5, 0.01) var update_interval := 0.08 +@export var path_clearings: Array[NodePath] = [] var _elapsed := 0.0 func _ready() -> void: + # JajceWorld first isolates its mutable materials in the parent's _ready(). + call_deferred("_configure_path_clearings") _refresh_interactors() +func _configure_path_clearings() -> void: + if grass_material == null: + return + var segments := PackedVector4Array() + segments.resize(CLEARING_LIMIT) + var widths := Vector4.ZERO + var count := 0 + for path in path_clearings.slice(0, CLEARING_LIMIT): + var strip := get_node_or_null(path) as MeshInstance3D + if strip == null or not strip.mesh is BoxMesh: + continue + var box := strip.mesh as BoxMesh + var half_length := strip.global_basis.z * box.size.z * 0.5 + var start := strip.global_position - half_length + var end := strip.global_position + half_length + segments[count] = Vector4(start.x, start.z, end.x, end.z) + widths[count] = strip.global_basis.x.length() * box.size.x * 0.5 + 0.12 + count += 1 + grass_material.set_shader_parameter("clearing_count", count) + grass_material.set_shader_parameter("clearing_segments", segments) + grass_material.set_shader_parameter("clearing_half_widths", widths) + + func _process(delta: float) -> void: _elapsed += delta if _elapsed < update_interval: diff --git a/world/jajce/JajceWorld.tscn b/world/jajce/JajceWorld.tscn index 7ecac5d..69ad6fe 100644 --- a/world/jajce/JajceWorld.tscn +++ b/world/jajce/JajceWorld.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=62 format=3] +[gd_scene load_steps=68 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"] @@ -31,6 +31,9 @@ [ext_resource type="PackedScene" path="res://world/animals/goat/dunja_habitat.tscn" id="29_habitat"] [ext_resource type="PackedScene" path="res://world/animals/goat/zora_shelter.tscn" id="30_zora_shelter"] +[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"] + [sub_resource type="Terrain3DMaterial" id="Terrain3DMaterial_jajce"] _shader_parameters = { "auto_base_texture": 0, @@ -38,8 +41,8 @@ _shader_parameters = { "auto_slope": 0.62, "blend_sharpness": 0.88, "enable_macro_variation": true, -"macro_variation1": Color(0.78, 0.9, 0.42, 1), -"macro_variation2": Color(0.55, 0.75, 0.38, 1), +"macro_variation1": Color(0.84, 0.95, 0.73, 1), +"macro_variation2": Color(0.60, 0.79, 0.61, 1), "macro_variation_slope": 0.48 } world_background = 0 @@ -172,13 +175,9 @@ size = Vector3(0.42, 0.08, 0.3) material = SubResource("Material_site_canvas") size = Vector3(2.9, 0.08, 1.4) -[sub_resource type="ProceduralSkyMaterial" id="SkyMaterial_jajce"] +[sub_resource type="ShaderMaterial" id="SkyMaterial_jajce"] resource_local_to_scene = true -sky_top_color = Color(0.28, 0.52, 0.72, 1) -sky_horizon_color = Color(0.88, 0.74, 0.52, 1) -ground_bottom_color = Color(0.18, 0.24, 0.14, 1) -ground_horizon_color = Color(0.68, 0.6, 0.44, 1) -sun_angle_max = 18.0 +shader = ExtResource("32_sky") [sub_resource type="Sky" id="Sky_jajce"] resource_local_to_scene = true @@ -196,17 +195,17 @@ reflected_light_source = 2 tonemap_mode = 2 glow_enabled = true glow_normalized = true -glow_intensity = 0.5 +glow_intensity = 0.18 glow_strength = 0.9 -glow_bloom = 0.12 +glow_bloom = 0.03 fog_enabled = true fog_light_color = Color(0.88, 0.84, 0.7, 1) fog_light_energy = 0.78 fog_density = 0.002 fog_height = -2.0 fog_height_density = 0.08 -fog_aerial_perspective = 0.35 -fog_sky_affect = 0.28 +fog_aerial_perspective = 0.5 +fog_sky_affect = 0.12 volumetric_fog_enabled = false volumetric_fog_density = 0.0035 volumetric_fog_albedo = Color(0.82, 0.9, 0.82, 1) @@ -215,9 +214,9 @@ volumetric_fog_emission_energy = 0.12 volumetric_fog_length = 96.0 volumetric_fog_detail_spread = 1.6 adjustment_enabled = true -adjustment_brightness = 1.08 -adjustment_contrast = 1.04 -adjustment_saturation = 1.08 +adjustment_brightness = 1.0 +adjustment_contrast = 1.03 +adjustment_saturation = 1.04 [sub_resource type="StandardMaterial3D" id="Material_guard"] albedo_color = Color(0.55, 0.45, 0.35, 1) @@ -269,6 +268,7 @@ rows = 34 amount = 1156 process_fixed_fps = 24 process_material = ExtResource("25_grass_process") +mesh = ExtResource("31_meadow") shadow_mode = 0 mesh_material_override = ExtResource("20_grass_material") min_draw_distance = 36.0 @@ -277,6 +277,7 @@ particle_count = 10404 [node name="GrassInteractionController" type="Node" parent="TerrainRoot"] script = ExtResource("21_grass_controller") grass_material = ExtResource("20_grass_material") +path_clearings = Array[NodePath]([NodePath("../../VillageRoot/Path_Village_Spine"), NodePath("../../VillageRoot/Path_Pantry_Worksites"), NodePath("../../VillageRoot/Path_River_Bridge"), NodePath("../../VillageRoot/Path_Forest_Edge")]) [node name="FortressBlockout" type="Node3D" parent="."] position = Vector3(-25, 7, 9) diff --git a/world/jajce/StylizedHouse.tscn b/world/jajce/StylizedHouse.tscn index f4df742..2f9d85c 100644 --- a/world/jajce/StylizedHouse.tscn +++ b/world/jajce/StylizedHouse.tscn @@ -1,33 +1,35 @@ -[gd_scene load_steps=15 format=3] +[gd_scene load_steps=17 format=3] -[sub_resource type="StandardMaterial3D" id="Material_foundation"] -albedo_color = Color(0.43, 0.42, 0.36, 1) -roughness = 0.98 +[ext_resource type="Shader" path="res://world/presentation/materials/storybook_surface.gdshader" id="story_surface"] + +[sub_resource type="ShaderMaterial" id="Material_foundation"] +shader = ExtResource("story_surface") +shader_parameter/albedo = Color(0.43, 0.42, 0.36, 1) [sub_resource type="BoxMesh" id="Mesh_foundation"] material = SubResource("Material_foundation") size = Vector3(4.35, 0.65, 5.35) -[sub_resource type="StandardMaterial3D" id="Material_walls"] -albedo_color = Color(0.78, 0.7, 0.56, 1) -roughness = 0.94 +[sub_resource type="ShaderMaterial" id="Material_walls"] +shader = ExtResource("story_surface") +shader_parameter/albedo = Color(0.78, 0.7, 0.56, 1) [sub_resource type="BoxMesh" id="Mesh_walls"] material = SubResource("Material_walls") size = Vector3(4, 2.75, 5) -[sub_resource type="StandardMaterial3D" id="Material_roof"] -albedo_color = Color(0.48, 0.2, 0.12, 1) -roughness = 0.9 +[sub_resource type="ShaderMaterial" id="Material_roof"] +shader = ExtResource("story_surface") +shader_parameter/albedo = Color(0.48, 0.2, 0.12, 1) [sub_resource type="PrismMesh" id="Mesh_roof"] material = SubResource("Material_roof") size = Vector3(4.65, 1.7, 5.65) left_to_right = 0.5 -[sub_resource type="StandardMaterial3D" id="Material_timber"] -albedo_color = Color(0.2, 0.105, 0.05, 1) -roughness = 0.92 +[sub_resource type="ShaderMaterial" id="Material_timber"] +shader = ExtResource("story_surface") +shader_parameter/albedo = Color(0.2, 0.105, 0.05, 1) [sub_resource type="BoxMesh" id="Mesh_beam_vertical"] material = SubResource("Material_timber") diff --git a/world/jajce/StylizedMill.tscn b/world/jajce/StylizedMill.tscn index 86b16bd..b9c5ee0 100644 --- a/world/jajce/StylizedMill.tscn +++ b/world/jajce/StylizedMill.tscn @@ -1,25 +1,27 @@ -[gd_scene load_steps=7 format=3] +[gd_scene load_steps=8 format=3] -[sub_resource type="StandardMaterial3D" id="Material_body"] -albedo_color = Color(0.55, 0.38, 0.22, 1) -roughness = 0.8 +[ext_resource type="Shader" path="res://world/presentation/materials/storybook_surface.gdshader" id="story_surface"] + +[sub_resource type="ShaderMaterial" id="Material_body"] +shader = ExtResource("story_surface") +shader_parameter/albedo = Color(0.55, 0.38, 0.22, 1) [sub_resource type="BoxMesh" id="Mesh_body"] material = SubResource("Material_body") size = Vector3(3, 3, 4) -[sub_resource type="StandardMaterial3D" id="Material_roof"] -albedo_color = Color(0.55, 0.30, 0.18, 1) -roughness = 0.75 +[sub_resource type="ShaderMaterial" id="Material_roof"] +shader = ExtResource("story_surface") +shader_parameter/albedo = Color(0.55, 0.30, 0.18, 1) [sub_resource type="PrismMesh" id="Mesh_roof"] material = SubResource("Material_roof") size = Vector3(3.4, 1.2, 4.4) left_to_right = 0.5 -[sub_resource type="StandardMaterial3D" id="Material_wheel"] -albedo_color = Color(0.20, 0.12, 0.06, 1) -roughness = 0.7 +[sub_resource type="ShaderMaterial" id="Material_wheel"] +shader = ExtResource("story_surface") +shader_parameter/albedo = Color(0.20, 0.12, 0.06, 1) [sub_resource type="CylinderMesh" id="Mesh_wheel"] material = SubResource("Material_wheel") diff --git a/world/jajce/StylizedTree.gd b/world/jajce/StylizedTree.gd index f003451..b45f989 100644 --- a/world/jajce/StylizedTree.gd +++ b/world/jajce/StylizedTree.gd @@ -7,9 +7,9 @@ enum CanopyVariant { } const COLORS := { - CanopyVariant.GREEN: Color(0.18, 0.39, 0.16), - CanopyVariant.DARK_GREEN: Color(0.10, 0.28, 0.12), - CanopyVariant.YELLOW_GREEN: Color(0.34, 0.47, 0.16), + 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), } const WIND_SHADER := preload("res://world/jajce/materials/wind.gdshader") @@ -25,7 +25,7 @@ func _ready() -> void: var rng := RandomNumberGenerator.new() rng.seed = hash_seed var canopy_variant := rng.randi() % COLORS.size() as CanopyVariant - var canopy_radius := 1.0 + rng.randf() * 0.55 + var canopy_radius := 1.25 + rng.randf() * 0.6 var wind_phase := rng.randf_range(0.0, TAU) var trunk_mesh := CylinderMesh.new() diff --git a/world/jajce/day_night_cycle.gd b/world/jajce/day_night_cycle.gd index 710b8eb..0b33f9d 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.23, 0.43, 0.62, 1) -const DAY_SKY_HORIZON := Color(0.82, 0.68, 0.5, 1) +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_GROUND_BOTTOM := Color(0.12, 0.17, 0.12, 1) -const DAY_GROUND_HORIZON := Color(0.62, 0.55, 0.42, 1) -const DAY_AMBIENT := Color(0.91, 0.85, 0.72, 1) -const DAY_AMBIENT_ENERGY := 0.7 -const DAY_FOG_LIGHT := Color(0.82, 0.78, 0.66, 1) -const DAY_FOG_LIGHT_ENERGY := 0.72 -const DAY_FOG_DENSITY := 0.002 -const DAY_LIGHT_COLOR := Color(1, 0.87, 0.72, 1) -const DAY_LIGHT_ENERGY := 1.45 +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_FOG_LIGHT_ENERGY := 0.8 +const DAY_FOG_DENSITY := 0.0015 +const DAY_LIGHT_COLOR := Color(1, 0.94, 0.79, 1) +const DAY_LIGHT_ENERGY := 1.2 const NIGHT_SKY_TOP := Color(0.04, 0.04, 0.12, 1) const NIGHT_SKY_HORIZON := Color(0.08, 0.06, 0.14, 1) @@ -123,6 +123,19 @@ func _apply_environment(cycle: float) -> void: mat.sky_horizon_color = _lerp_color(NIGHT_SKY_HORIZON, DAY_SKY_HORIZON, day_factor) mat.ground_bottom_color = _lerp_color(NIGHT_GROUND_BOTTOM, DAY_GROUND_BOTTOM, day_factor) mat.ground_horizon_color = _lerp_color(NIGHT_GROUND_HORIZON, DAY_GROUND_HORIZON, day_factor) + elif sky != null and sky.sky_material is ShaderMaterial: + var mat := sky.sky_material as ShaderMaterial + mat.set_shader_parameter("sky_top_color", NIGHT_SKY_TOP.lerp(DAY_SKY_TOP, day_factor)) + mat.set_shader_parameter( + "sky_horizon_color", NIGHT_SKY_HORIZON.lerp(DAY_SKY_HORIZON, day_factor) + ) + mat.set_shader_parameter( + "ground_bottom_color", NIGHT_GROUND_BOTTOM.lerp(DAY_GROUND_BOTTOM, day_factor) + ) + mat.set_shader_parameter( + "ground_horizon_color", NIGHT_GROUND_HORIZON.lerp(DAY_GROUND_HORIZON, day_factor) + ) + mat.set_shader_parameter("daylight", day_factor) func _find_simulation_node() -> void: diff --git a/world/jajce/materials/cozy_grass.gdshader b/world/jajce/materials/cozy_grass.gdshader index 5a5ee00..80a5ea8 100644 --- a/world/jajce/materials/cozy_grass.gdshader +++ b/world/jajce/materials/cozy_grass.gdshader @@ -1,70 +1,66 @@ -// Adapted for The Steward from the bundled Terrain3D particle-grass example. +// Original Blender tuft. Opaque blades avoid alpha overdraw and sorting. shader_type spatial; - -render_mode skip_vertex_transform, cull_disabled, blend_mix; +render_mode skip_vertex_transform, cull_disabled, specular_disabled; const int MAX_INTERACTORS = 8; - uniform vec2 wind_direction = vec2(1.0, 0.7); -uniform vec3 base_color : source_color = vec3(0.16, 0.31, 0.08); -uniform vec3 tip_color : source_color = vec3(0.54, 0.68, 0.24); -uniform float wind_amount : hint_range(0.0, 1.0) = 0.34; +uniform vec3 base_color : source_color = vec3(0.23, 0.40, 0.22); +uniform vec3 tip_color : source_color = vec3(0.53, 0.67, 0.35); +uniform float wind_amount : hint_range(0.0, 1.0) = 0.24; uniform int interactor_count : hint_range(0, 8) = 0; uniform vec3 interactor_positions[MAX_INTERACTORS]; uniform float interaction_radius : hint_range(0.25, 4.0) = 1.8; -uniform float interaction_strength : hint_range(0.0, 2.0) = 0.75; - -varying vec4 grass_data; +uniform float interaction_strength : hint_range(0.0, 2.0) = 0.55; +uniform int clearing_count = 0; +uniform vec4 clearing_segments[4]; +uniform vec4 clearing_half_widths = vec4(0.0); +varying float blade_height; +varying float shade_variation; varying float pressed_weight; - void vertex() { - grass_data[2] = VERTEX.y + 0.55; - grass_data[2] *= grass_data[2]; - VERTEX.xz *= 1.0 - grass_data[2]; - COLOR = mix(COLOR, vec4(1.0), smoothstep(0.9, 1.0, grass_data[2])); - COLOR *= INSTANCE_CUSTOM[3] < 0.35 ? 1.0 : mix(1.0, 0.75, smoothstep(0.35, 0.0, grass_data[2])); - grass_data.rg = INSTANCE_CUSTOM.rg; - + blade_height = UV.y; + float tip_weight = blade_height * blade_height; + vec3 anchor = MODEL_MATRIX[3].xyz; + float clearing_scale = 1.0; + for (int index = 0; index < 4; index++) { + if (index >= clearing_count) { break; } + vec2 start = clearing_segments[index].xy; + vec2 line = clearing_segments[index].zw - start; + float along = clamp(dot(anchor.xz - start, line) / max(dot(line, line), 0.001), 0.0, 1.0); + float distance_to_path = length(anchor.xz - start - line * along); + clearing_scale *= smoothstep(clearing_half_widths[index], clearing_half_widths[index] + 0.20, distance_to_path); + } + VERTEX *= clearing_scale; + tip_weight *= clearing_scale; vec3 world_vertex = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz; - float scale = pow(INSTANCE_CUSTOM[3] * INSTANCE_CUSTOM[3], 0.707); - float force = INSTANCE_CUSTOM[2] * grass_data[2] * scale * wind_amount; - force -= fract(force * 256.0) * force * 0.05; - force = pow(max(force, 0.0), 0.707); - float lateral_wobble = sin(TIME * 2.0 * (1.0 + grass_data.r + grass_data.g)) * 0.2 * (1.0 - INSTANCE_CUSTOM[2]); - vec2 direction = normalize(wind_direction); - world_vertex.xz -= (vec2(-direction.y, direction.x) * lateral_wobble + direction) * force; - world_vertex.y -= INSTANCE_CUSTOM[2] * force * grass_data[2]; - grass_data[3] = force; - + 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; + world_vertex.xz += normalize(wind_direction) * gust * wind_amount * tip_weight; + shade_variation = sin(anchor.x * 0.30 + anchor.z * 0.21) * 0.5 + 0.5; pressed_weight = 0.0; for (int index = 0; index < MAX_INTERACTORS; index++) { - if (index >= interactor_count) { - break; - } - vec2 offset = world_vertex.xz - interactor_positions[index].xz; + if (index >= interactor_count) { break; } + vec2 offset = anchor.xz - interactor_positions[index].xz; float distance_to_actor = length(offset); float influence = 1.0 - smoothstep(0.25, interaction_radius, distance_to_actor); vec2 away = offset / max(distance_to_actor, 0.08); - world_vertex.xz += away * influence * interaction_strength * grass_data[2]; - world_vertex.y -= influence * 0.28 * grass_data[2]; + world_vertex.xz += away * influence * interaction_strength * tip_weight; + world_vertex.y -= influence * 0.18 * tip_weight; pressed_weight = max(pressed_weight, influence); } - VERTEX = (VIEW_MATRIX * vec4(world_vertex, 1.0)).xyz; - NORMAL = MODELVIEW_NORMAL_MATRIX * NORMAL; - BINORMAL = MODELVIEW_NORMAL_MATRIX * BINORMAL; - TANGENT = MODELVIEW_NORMAL_MATRIX * TANGENT; + NORMAL = normalize((VIEW_MATRIX * vec4(0.0, 1.0, 0.0, 0.0)).xyz); } - void fragment() { - vec3 color = mix(base_color, tip_color, grass_data[2] * 0.62); - color.rg *= grass_data.rg * 0.18 + 0.91; - color *= pow(COLOR.rgb, vec3(2.2)); - ALBEDO = mix(color, base_color * 0.72, pressed_weight * 0.42); - float specular_roughness = clamp(max(grass_data[2], grass_data[3]), 0.0, 1.0); - ROUGHNESS = 1.0 - specular_roughness * 0.22; - SPECULAR = clamp(specular_roughness * 0.2, 0.0, 0.12); - BACKLIGHT = tip_color * 0.24; + vec3 color = mix(base_color, tip_color, smoothstep(0.05, 1.0, blade_height) * 0.78); + color *= mix(0.90, 1.09, shade_variation); + ALBEDO = mix(color, base_color, pressed_weight * 0.15); + ROUGHNESS = 1.0; +} + +void light() { + float band = smoothstep(0.05, 0.16, dot(NORMAL, LIGHT)); + DIFFUSE_LIGHT += mix(vec3(0.46, 0.60, 0.52), vec3(1.0, 0.98, 0.85), band) * LIGHT_COLOR * ATTENUATION / PI; } diff --git a/world/jajce/materials/cozy_grass_material.tres b/world/jajce/materials/cozy_grass_material.tres index 3fee207..64402f4 100644 --- a/world/jajce/materials/cozy_grass_material.tres +++ b/world/jajce/materials/cozy_grass_material.tres @@ -7,10 +7,10 @@ resource_local_to_scene = true render_priority = 0 shader = ExtResource("1_grass") shader_parameter/wind_direction = Vector2(1, 0.7) -shader_parameter/base_color = Color(0.16, 0.31, 0.08, 1) -shader_parameter/tip_color = Color(0.54, 0.68, 0.24, 1) -shader_parameter/wind_amount = 0.34 +shader_parameter/base_color = Color(0.23, 0.40, 0.22, 1) +shader_parameter/tip_color = Color(0.53, 0.67, 0.35, 1) +shader_parameter/wind_amount = 0.24 shader_parameter/interactor_count = 0 shader_parameter/interactor_positions = PackedVector3Array(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) shader_parameter/interaction_radius = 1.8 -shader_parameter/interaction_strength = 0.75 +shader_parameter/interaction_strength = 0.55 diff --git a/world/jajce/materials/cozy_grass_process_material.tres b/world/jajce/materials/cozy_grass_process_material.tres index dd0520d..633af02 100644 --- a/world/jajce/materials/cozy_grass_process_material.tres +++ b/world/jajce/materials/cozy_grass_process_material.tres @@ -16,13 +16,13 @@ resource_local_to_scene = true shader = ExtResource("1_process_shader") shader_parameter/main_noise = SubResource("NoiseTexture_grass") shader_parameter/main_noise_scale = 0.012 -shader_parameter/position_offset = Vector3(0, 0.22, 0) +shader_parameter/position_offset = Vector3(0, 0.015, 0) shader_parameter/align_to_normal = true shader_parameter/normal_strength = 0.22 shader_parameter/random_rotation = true shader_parameter/random_spacing = 0.72 -shader_parameter/min_scale = Vector3(0.09, 0.22, 0.09) -shader_parameter/max_scale = Vector3(0.13, 0.48, 0.13) +shader_parameter/min_scale = Vector3(0.72, 0.28, 0.72) +shader_parameter/max_scale = Vector3(1.15, 0.48, 1.15) shader_parameter/noise_scale = 0.0041 shader_parameter/wind_speed = 0.022 shader_parameter/wind_strength = 0.6 diff --git a/world/jajce/materials/storybook_sky.gdshader b/world/jajce/materials/storybook_sky.gdshader new file mode 100644 index 0000000..1672fc1 --- /dev/null +++ b/world/jajce/materials/storybook_sky.gdshader @@ -0,0 +1,27 @@ +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); +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 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); +} + +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); + 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/storybook_sky.gdshader.uid b/world/jajce/materials/storybook_sky.gdshader.uid new file mode 100644 index 0000000..3e803fd --- /dev/null +++ b/world/jajce/materials/storybook_sky.gdshader.uid @@ -0,0 +1 @@ +uid://c10spbmnnwwto diff --git a/world/jajce/materials/wind.gdshader b/world/jajce/materials/wind.gdshader index 05b30db..c708dbd 100644 --- a/world/jajce/materials/wind.gdshader +++ b/world/jajce/materials/wind.gdshader @@ -10,8 +10,10 @@ 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 vec3 albedo : source_color = vec3(0.19, 0.38, 0.17); uniform float roughness : hint_range(0.0, 1.0) = 0.9; +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); height_factor = height_factor * height_factor; @@ -29,6 +31,17 @@ void vertex() { } void fragment() { - ALBEDO = albedo; + 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); ROUGHNESS = roughness; + SPECULAR = 0.0; +} + +void light() { + float band = smoothstep(0.08, 0.18, dot(NORMAL, LIGHT)); + float highlight = smoothstep(0.62, 0.74, dot(NORMAL, LIGHT)); + vec3 tone = mix(vec3(0.32, 0.48, 0.39), vec3(0.87, 0.92, 0.72), band); + tone = mix(tone, vec3(1.12, 1.08, 0.82), highlight); + DIFFUSE_LIGHT += tone * ATTENUATION * LIGHT_COLOR / PI; } diff --git a/world/presentation/AnimalAppearance.gd b/world/presentation/AnimalAppearance.gd new file mode 100644 index 0000000..01015a9 --- /dev/null +++ b/world/presentation/AnimalAppearance.gd @@ -0,0 +1,38 @@ +class_name AnimalAppearance +extends RefCounted +## Disposable woodland identities, rebuilt from stable NPC IDs. No simulation RNG. + +const CEL_SHADER := preload("res://world/presentation/materials/storybook_cel.gdshader") +const SPECIES: Array[StringName] = [&"fox", &"rabbit", &"badger", &"otter"] +const MODELS: Array[PackedScene] = [ + preload("res://assets/storybook/fox.glb"), + preload("res://assets/storybook/rabbit.glb"), + preload("res://assets/storybook/badger.glb"), + preload("res://assets/storybook/otter.glb"), +] +const PARTS: Array[StringName] = [ + &"Body", &"Head", &"Hair", &"ArmLeft", &"ArmRight", &"LegLeft", &"LegRight", &"Tail" +] + + +static func apply_to(root: Node3D, identity: int, npc_body := false) -> void: + var index := posmod(identity, MODELS.size()) + var source := MODELS[index].instantiate() + var material := ShaderMaterial.new() + material.shader = CEL_SHADER + root.set_meta("animal_species", SPECIES[index]) + for part in PARTS: + var source_mesh := source.find_child(String(part), true, false) as MeshInstance3D + var target_name := "MeshInstance3D" if npc_body and part == &"Body" else String(part) + var target := root.get_node(target_name) as MeshInstance3D + assert(source_mesh != null, "Missing Blender animation part: " + String(part)) + target.mesh = source_mesh.mesh + target.material_override = material + source.free() + + +static func set_profession_color(root: Node3D, color: Color) -> void: + var body := root.get_node("MeshInstance3D") as MeshInstance3D + var material := body.material_override as ShaderMaterial + material.set_shader_parameter("coat_color", color) + material.set_shader_parameter("profession_tint", 0.28) diff --git a/world/presentation/AnimalAppearance.gd.uid b/world/presentation/AnimalAppearance.gd.uid new file mode 100644 index 0000000..30fcb1a --- /dev/null +++ b/world/presentation/AnimalAppearance.gd.uid @@ -0,0 +1 @@ +uid://dfb2a5yut45av diff --git a/world/presentation/materials/storybook_cel.gdshader b/world/presentation/materials/storybook_cel.gdshader new file mode 100644 index 0000000..f849de4 --- /dev/null +++ b/world/presentation/materials/storybook_cel.gdshader @@ -0,0 +1,23 @@ +shader_type spatial; +render_mode specular_disabled; + +// Opaque vertex-painted models: alpha is a clothing mask, never transparency. +uniform vec4 coat_color : source_color = vec4(1.0); +uniform float profession_tint : hint_range(0.0, 1.0) = 0.0; +uniform vec3 shadow_color : source_color = vec3(0.55, 0.64, 0.70); +uniform float band_softness : hint_range(0.005, 0.2) = 0.045; + +void fragment() { + ALBEDO = mix(COLOR.rgb, COLOR.rgb * coat_color.rgb * 1.6, COLOR.a * profession_tint); + ROUGHNESS = 1.0; + SPECULAR = 0.0; +} + +void light() { + float diffuse = dot(NORMAL, LIGHT); + float mid = smoothstep(-0.15 - band_softness, -0.15 + band_softness, diffuse); + float sun = smoothstep(0.50 - band_softness, 0.50 + band_softness, diffuse); + vec3 tone = mix(shadow_color * 0.36, vec3(0.70), mid); + tone = mix(tone, vec3(1.0, 0.97, 0.87), sun); + DIFFUSE_LIGHT += tone * ATTENUATION * LIGHT_COLOR / PI; +} diff --git a/world/presentation/materials/storybook_cel.gdshader.uid b/world/presentation/materials/storybook_cel.gdshader.uid new file mode 100644 index 0000000..654afbd --- /dev/null +++ b/world/presentation/materials/storybook_cel.gdshader.uid @@ -0,0 +1 @@ +uid://bs1ip7dyu7jy8 diff --git a/world/presentation/materials/storybook_surface.gdshader b/world/presentation/materials/storybook_surface.gdshader new file mode 100644 index 0000000..d8250a6 --- /dev/null +++ b/world/presentation/materials/storybook_surface.gdshader @@ -0,0 +1,24 @@ +shader_type spatial; +render_mode specular_disabled; +uniform vec3 albedo : source_color = vec3(0.8, 0.72, 0.52); +uniform float paint_strength : hint_range(0.0, 0.2) = 0.04; +varying vec3 paint_position; + +void vertex() { + paint_position = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz; +} + +void fragment() { + float brush = sin(paint_position.x * 2.1 + sin(paint_position.y * 3.0)) * sin(paint_position.z * 2.8 + paint_position.y); + ALBEDO = albedo * (1.0 + brush * paint_strength); + ROUGHNESS = 1.0; +} + +void light() { + float ndl = dot(NORMAL, LIGHT); + float middle = smoothstep(-0.10, 0.03, ndl); + float sunny = smoothstep(0.48, 0.58, ndl); + vec3 shade = mix(vec3(0.26, 0.34, 0.40), vec3(0.74), middle); + shade = mix(shade, vec3(1.0, 0.97, 0.88), sunny); + DIFFUSE_LIGHT += shade * ATTENUATION * LIGHT_COLOR / PI; +} diff --git a/world/presentation/materials/storybook_surface.gdshader.uid b/world/presentation/materials/storybook_surface.gdshader.uid new file mode 100644 index 0000000..32e5666 --- /dev/null +++ b/world/presentation/materials/storybook_surface.gdshader.uid @@ -0,0 +1 @@ +uid://c8biomtfwtf4w