feat(art): add storybook meadow and woodland villagers

This commit is contained in:
Rijad Zuzo
2026-09-05 18:56:44 +02:00
parent 6aae41d36f
commit da3bed67a0
60 changed files with 1222 additions and 373 deletions
+1
View File
@@ -0,0 +1 @@
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+83
View File
@@ -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,88410,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).
Binary file not shown.
+45
View File
@@ -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
Binary file not shown.
+45
View File
@@ -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
Binary file not shown.
+45
View File
@@ -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
Binary file not shown.
+27
View File
@@ -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
}
}
Binary file not shown.
+45
View File
@@ -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
Binary file not shown.
+45
View File
@@ -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
+19
View File
@@ -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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 809 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 798 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 937 KiB

@@ -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"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

+8 -108
View File
@@ -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)
+5 -19
View File
@@ -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
+12 -103
View File
@@ -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)
+1
View File
@@ -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:
+1 -1
View File
@@ -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"
)
+9
View File
@@ -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"
+20 -5
View File
@@ -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"
+1
View File
@@ -0,0 +1 @@
+280
View File
@@ -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))
+171
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://bqcj3uman431w
+12
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://blkknmqjyo6uj
+12 -10
View File
@@ -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")
+21 -19
View File
@@ -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")
+27
View File
@@ -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:
+17 -16
View File
@@ -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)
+15 -13
View File
@@ -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")
+12 -10
View File
@@ -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")
+4 -4
View File
@@ -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()
+23 -10
View File
@@ -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:
+43 -47
View File
@@ -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;
}
@@ -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
@@ -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
@@ -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));
}
@@ -0,0 +1 @@
uid://c10spbmnnwwto
+14 -1
View File
@@ -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;
}
+38
View File
@@ -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)
@@ -0,0 +1 @@
uid://dfb2a5yut45av
@@ -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;
}
@@ -0,0 +1 @@
uid://bs1ip7dyu7jy8
@@ -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;
}
@@ -0,0 +1 @@
uid://c8biomtfwtf4w