Compare commits

..

4 Commits

Author SHA1 Message Date
Rijad Zuzo 70ee6feddf chore(godot): finalize sky import and project settings 2026-09-05 20:40:33 +02:00
Rijad Zuzo ec16e2f2ca feat(environment): add painterly skies and living meadows 2026-09-05 20:27:09 +02:00
Rijad Zuzo 7899d36f03 feat(foliage): add recovering grass trails and bush contact 2026-09-05 19:30:46 +02:00
Rijad Zuzo da3bed67a0 feat(art): add storybook meadow and woodland villagers 2026-09-05 18:56:44 +02:00
138 changed files with 4133 additions and 561 deletions
+1
View File
@@ -0,0 +1 @@
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
View File
Binary file not shown.
Binary file not shown.
+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.
+21
View File
@@ -0,0 +1,21 @@
# Jajce meadow flowers
Original mesh assets built for The Steward. Four small clumps give authored
meadow drifts a cream, rose, butter-yellow and lavender palette. Each contains
three stems, folded leaves and opaque petal geometry with painted vertex colors.
There are no external textures, dependencies, collision shapes or gameplay IDs.
Editable sources live in `art/meadow/`, outside Godot's import tree. Rebuild with
Blender 5.1 or later:
```sh
Blender --background --python tools/art/build_meadow_flowers.py
```
`mesh_budget.json` records exported triangle/surface/file-size counts. Runtime
`PainterlyMeadow` uses one MultiMesh per species and its own soft-lit wind shader;
the GLB material preserves the original palette when opened in other tools.
Godot 4.7 Compatibility requires explicit white MultiMesh instance colors when
the custom instance buffer is enabled. `PainterlyMeadow` supplies that neutral
color so both Compatibility and Forward+ preserve the painted GLB palette.
Binary file not shown.
+45
View File
@@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://cnoywbu65c0hg"
path="res://.godot/imported/cream_daisy.glb-7a51f417838affb7d915e8a81cdabcc9.scn"
[deps]
source_file="res://assets/meadow/cream_daisy.glb"
dest_files=["res://.godot/imported/cream_daisy.glb-7a51f417838affb7d915e8a81cdabcc9.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
gltf/texture_map_mode=1
Binary file not shown.
+45
View File
@@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://dur42glc0ybm3"
path="res://.godot/imported/lavender_spire.glb-ca4666e5e14c84270d4621d49918d5e7.scn"
[deps]
source_file="res://assets/meadow/lavender_spire.glb"
dest_files=["res://.godot/imported/lavender_spire.glb-ca4666e5e14c84270d4621d49918d5e7.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
gltf/texture_map_mode=1
+26
View File
@@ -0,0 +1,26 @@
{
"cream_daisy": {
"triangles": 180,
"surfaces": 1,
"flower_stems": 3,
"bytes": 19896
},
"pink_cosmos": {
"triangles": 171,
"surfaces": 1,
"flower_stems": 3,
"bytes": 18888
},
"yellow_buttercup": {
"triangles": 153,
"surfaces": 1,
"flower_stems": 3,
"bytes": 16840
},
"lavender_spire": {
"triangles": 360,
"surfaces": 1,
"flower_stems": 3,
"bytes": 40444
}
}
Binary file not shown.
+45
View File
@@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://dqksrghyqlfec"
path="res://.godot/imported/pink_cosmos.glb-f2a2c19fa8aeebec56761be5c75e2426.scn"
[deps]
source_file="res://assets/meadow/pink_cosmos.glb"
dest_files=["res://.godot/imported/pink_cosmos.glb-f2a2c19fa8aeebec56761be5c75e2426.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
gltf/texture_map_mode=1
Binary file not shown.
+45
View File
@@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://urkk7ybwrnv7"
path="res://.godot/imported/yellow_buttercup.glb-88c3511b4d2c884beab5a1c921cdc3cb.scn"
[deps]
source_file="res://assets/meadow/yellow_buttercup.glb"
dest_files=["res://.godot/imported/yellow_buttercup.glb-88c3511b4d2c884beab5a1c921cdc3cb.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
gltf/texture_map_mode=1
+59
View File
@@ -0,0 +1,59 @@
# Painterly valley assets
Original broadleaf and evergreen meshes created in Blender for The Steward.
Scalloped crowns, painted vertex variation, crooked forks and root flares replace
the primitive tree silhouettes. Editable sources are in `art/painterly/`;
`.gdignore` keeps Blender outside Godot's runtime import dependencies. The two
portable GLBs use opaque geometry without textures or alpha cards.
## Rebuild and mesh budget
From the project root, using Blender 5.1 and Godot 4.7:
```sh
"$BLENDER_BIN" --background --python tools/art/build_painterly_trees.py
"$GODOT_BIN" --headless --editor --path "$PWD" --import
```
Use the isolated Godot profile configured by `tools/quality.sh` for headless
validation. Runtime consumes the GLBs directly; no mesh-extraction step is
needed. Preserve their `.import` files when updating exports.
| Geometry | Triangles | Surfaces |
| --- | ---: | ---: |
| Shared broadleaf crown | 1,236 | 1 |
| Joined broadleaf trunk, forks and roots | 406 | 1 |
| Complete broadleaf: four crowns plus trunk | 5,350 | 5 |
| Complete conifer: boughs plus trunk | 1,580 | 2 |
`tree_mesh_budget.json` records these export counts. The broadleaf GLB is
45,676 bytes; the conifer GLB is 40,836 bytes and stands 3.10 m before placement
scaling. A separate temporary rebuild with Blender 5.1.2 produced byte-identical
copies of both GLBs and the budget JSON. This establishes generator
reproducibility with that Blender version, not identical `.blend` serialization
across versions.
`StylizedTree` shares imported meshes and isolates crown tint/wind materials.
Its four direct `Canopy*` children retain the existing thinning and stump
presentation contract. `PainterlyConifer` shares imported bough/trunk meshes.
Both use the same world-direction breeze and explicit painted bark colors;
neither adds simulation state, collision or per-frame GDScript work.
## Painted sky provenance
`painted_sky.png` is an original 1774 × 887 image generated with the built-in
ImageGen tool, rather than a CLI generator. The prompt asks for a painted
cumulus panorama with warm ivory cloud tops, cool turquoise cloud shadows and
a clear blue summer atmosphere. Film references informed the color and mood;
no external film image was copied into the texture. The exact generation
prompt is recorded in [sky_prompt.md](sky_prompt.md).
`storybook_sky.gdshader` maps the panorama's horizon at 69% of its height onto
the upper hemisphere, repeats the painted band twice around the azimuth to
retain cloud proportions, blends its seam/edge into haze, and follows the existing
day/night palette. The image supplies the sky backdrop only; it does not paint
terrain or create volumetric clouds.
Final renderer QA and the complete project quality gate belong to the
integrated environment review. See the [presentation guide](../../docs/FEATURE_PRESENTATION_PERFORMANCE.md)
for current verification evidence and remaining hardware limits.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

+41
View File
@@ -0,0 +1,41 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dmkubk6dhhyis"
path.s3tc="res://.godot/imported/painted_sky.png-9888802278aff2f83effdab2649f1f05.s3tc.ctex"
metadata={
"imported_formats": ["s3tc_bptc"],
"vram_texture": true
}
[deps]
source_file="res://assets/painterly/painted_sky.png"
dest_files=["res://.godot/imported/painted_sky.png-9888802278aff2f83effdab2649f1f05.s3tc.ctex"]
[params]
compress/mode=2
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=0
Binary file not shown.
@@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://clcvatvbyixma"
path="res://.godot/imported/painterly_conifer.glb-596d48094a6a072cfc85374d478c7bbe.scn"
[deps]
source_file="res://assets/painterly/painterly_conifer.glb"
dest_files=["res://.godot/imported/painterly_conifer.glb-596d48094a6a072cfc85374d478c7bbe.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
gltf/texture_map_mode=1
Binary file not shown.
@@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://blu5r5vupqvpd"
path="res://.godot/imported/painterly_tree.glb-12cb20acdabe08bb0fe06cba12958067.scn"
[deps]
source_file="res://assets/painterly/painterly_tree.glb"
dest_files=["res://.godot/imported/painterly_tree.glb-12cb20acdabe08bb0fe06cba12958067.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
gltf/texture_map_mode=1
+35
View File
@@ -0,0 +1,35 @@
# Painted sky provenance
Created for this project with the built-in imagegen tool on 2026-09-05.
Original output: 1774 × 887 PNG, copied without pixel editing to
`assets/painterly/painted_sky.png`. The sky shader samples the upper portion
and blends its horizon into the simulation-driven day/night palette.
No film frame or downloaded artwork is used as a runtime texture.
## Generation prompt
Use case: stylized-concept. Asset type: original 360-degree panoramic sky
texture for a Godot game, 2:1 equirectangular landscape, ideally 3072x1536.
Create ONLY SKY, absolutely no ground, hills, buildings, ocean, objects, text
or sun disk. Art direction: exquisitely hand-painted Japanese animation
background, Studio Ghibli inspired summer pastoral fantasy, gouache brush
texture, saturated clear turquoise-blue atmosphere, towering luminous cumulus
clouds with creamy warm sunlit edges, intricate scalloped billowing forms,
pale cyan and slate blue softly painted shadow volumes. Composition is a full
spherical sky environment texture: upper half of image shows the upward
hemisphere, zenith at top is mostly deep clean blue, large beautiful cumulus
towers occupy the upper middle band between y=15% and y=44%, and the horizon
at exactly image vertical halfway is uniform pale atmospheric aqua. Lower
half is entirely an unobtrusive smooth pale aqua color gradient, with NO
landscape. Cloud coverage about 40% of upper hemisphere, generous varied blue
gaps, asymmetrical naturally spaced cloud masses. Left and right edges must
wrap seamlessly in color and shape, keep thin strips at far left/right clear
sky to avoid seam. No photographic cloud noise, no 3D render or vector
outlines, no flat cartoon circles. Painterly refined layered brushwork like a
cinematic animated film background, not a flat gradient. Use only original
cloud shapes; no copied film imagery.
The returned image does not exactly meet the requested resolution or horizon
position. The runtime shader uses its actual dimensions and remaps the painted
horizon at 69% image height. Blender mesh assets are deterministic rebuilds;
this generated image is a pinned art asset, not a deterministic generator output.
+24
View File
@@ -0,0 +1,24 @@
{
"Canopy": {
"triangles": 1236,
"surfaces": 1,
"vertices": 712
},
"Trunk": {
"triangles": 406,
"surfaces": 1,
"vertices": 221
},
"runtime_tree": {
"triangles": 5350,
"surfaces": 5,
"shared_meshes": 2
},
"glb_bytes": 45676,
"conifer": {
"triangles": 1580,
"surfaces": 2,
"height_m": 3.1,
"glb_bytes": 40836
}
}
+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
+148 -4
View File
@@ -38,19 +38,81 @@ spawn is a next integration.
`JajceWorld` owns reversible High, Balanced, and Low presentation profiles with `JajceWorld` owns reversible High, Balanced, and Low presentation profiles with
instance-local mutable environment/grass resources and viewport-scale ownership. instance-local mutable environment/grass resources and viewport-scale ownership.
| Profile | 3D scale | Shadow distance | Volumetric/glow | Grass | | Profile | 3D scale / MSAA | Shadow distance | Volumetric/glow | Grass |
| --- | ---: | ---: | --- | --- | | --- | ---: | ---: | --- | --- |
| High | authored | authored (currently 180 m) | authored when renderer supports it | authored density/interactors | | High | authored / authored 4× | authored (currently 180 m) | authored when renderer supports it | authored density/interactors |
| Balanced (default) | 0.85 cap | 120 m cap | authored ordinary lookdev; unsupported volumetric disabled | reduced spacing/interactors/update rate | | Balanced (default) | 0.85 cap / 2× cap | 120 m cap | authored ordinary lookdev; unsupported volumetric disabled | reduced spacing/interactors/update rate |
| Low | 0.70 cap | 80 m, orthogonal | volumetric/glow/adjustment disabled; ordinary fog retained | hidden/stopped, emitters off, shader interactors zero | | Low | 0.70 cap / off | 80 m, orthogonal | volumetric/glow/adjustment disabled; ordinary fog retained | hidden/stopped, emitters off, shader interactors zero |
UI remains at native resolution. High/Low switches restore captured authored UI remains at native resolution. High/Low switches restore captured authored
values; viewport scale restoration is guarded by the current quality owner so a values; viewport scale restoration is guarded by the current quality owner so a
second world cannot clobber the active owner. Compatibility/mobile feature second world cannot clobber the active owner. Compatibility/mobile feature
selection never forces unsupported volumetric fog on. selection never forces unsupported volumetric fog on.
## Grass trails and bush contact
`GrassInteractionController` samples the existing `grass_interactors` group:
player, NPCs, hostile creatures and animals. The player keeps one slot; other
visible actors are ranked by camera distance within the loaded actor container.
High updates at 0.08 seconds with eight actors; Balanced uses 0.12 seconds and
four. Low stops updates and immediately releases both grass and bushes.
`FoliageTrailMap` maintains a disposable 128×128 RGBA float texture covering a
64 m square around the camera. Swept footprints part grass to either side and
lower its tips; the field recovers over eight seconds after contact. Sparse
history is clipped to the moving window. The 256 KiB texture is reused with
[`ImageTexture.update`](https://docs.godotengine.org/en/stable/classes/class_imagetexture.html#class-imagetexture-method-update),
at most 3.125 MiB/s of texture data at the High update rate. The vertex shader
adds one field lookup per grass vertex; there are no blade collision bodies,
per-blade scripts, render targets or added grass draw calls.
`StylizedBerryPatch` shares the field and contact positions between its leaf
and berry MultiMeshes. Both bend in world space around the planted bush root,
including under rotated/scaled parents, and spring back faster than grass.
The five village berry placeholders now use this same amount-aware presentation
as the existing riverbank bush. Contact does not change berries, resource
amounts, navigation, targets or collision geometry.
The controller clears history after `SimulationManager.state_restored`, on
quality changes and on world disposal. Missing/recreated actors, teleports over
3 m between samples and update gaps over 0.5 seconds start a new footprint rather
than a connecting trail. Actor foot height gates deformation so bridge traffic
does not flatten grass below. Trails are visual only and never enter saves.
The exported actor, primary actor, foliage and simulation paths can be rebound
when placing the controller in another loaded scene container.
`tests/foliage_interaction_test.gd` checks sweep direction, recovery, teleport
rejection, camera movement, world isolation, restore/quality reset, the primary
actor slot and shared leaf/fruit bindings. Run `tools/capture_foliage.gd` with
the native renderer for before/contact/trail/recovery images and controller
timing in `docs/baselines/foliage_*`. That capture freezes simulation and wind, and moves
the player presentation through the actual meadow, checking the state checksum
is unchanged. CPU update timing is not a GPU or weak-PC frame-rate guarantee.
Native Metal and Compatibility both render the effect; existing particle-shader
shutdown warnings (and four Compatibility texture leaks) also reproduce in the
untouched pre-art baseline. The headless quality gate retains its exact allowlist.
## Runtime hot paths already bounded ## 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 painted cloud panorama, cooler
ambient fill and warm sunlight follow the simulation day/night clock. The
cloud shader adds no volumetric pass, outline pass or screen-space postprocess.
See [the asset contract and rebuild commands](../assets/storybook/README.md).
`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; - Player interaction and villager inspection probes are throttled/event-driven;
they no longer rebuild all candidate data every rendered frame. they no longer rebuild all candidate data every rendered frame.
- `LoadedResourceSpatialIndex` bounds finite-resource discovery. - `LoadedResourceSpatialIndex` bounds finite-resource discovery.
@@ -99,3 +161,85 @@ ledger, not brittle cross-machine CI thresholds.
See [the benchmark ledger](benchmarks/README.md), [regional contract](REGIONAL_SIMULATION.md), See [the benchmark ledger](benchmarks/README.md), [regional contract](REGIONAL_SIMULATION.md),
and [the build-in-public visual plan](BUILD_IN_PUBLIC_PLAN.md). and [the build-in-public visual plan](BUILD_IN_PUBLIC_PLAN.md).
## Painterly environment pass — September 2026
The reference study highlighted flat cloud bands, primitive tree silhouettes,
sparse ground cover, opaque green water, and a sun arc offset from daytime
brightness. The current pass addresses those through shared presentation assets:
- The WorldEnvironment uses cool blue ambient fill, warm sunlight, restrained
glow and ordinary aerial fog. The solar arc now peaks at noon and the warm
transitions overlap dawn/dusk. An original painted cloud panorama supplies
layered cumulus shapes; a static sky shader remaps the artwork, blends its
seam/horizon, and tints it from the simulation clock. It adds no TIME-driven
cubemap updates or volumetric cloud pass. Generation provenance and the exact
prompt are in [the asset guide](../assets/painterly/README.md).
- Original Blender broadleaf crowns/trunks and layered conifers replace the
primitive foliage. Seventeen decorative edge trees form a woodland backdrop.
The four village tree placeholders now use `HarvestableTreePresentation`,
retaining their existing IDs, amounts, interaction points and resource count.
Full/low/depleted state and restored crowns still derive from real amounts.
- `PainterlyMeadow` creates at most 1,680 decorative flower clumps in four
MultiMesh batches, with cream daisies, pink cosmos, buttercups and lavender.
Local seeded drifts follow terrain and exclude houses, paths, work sites and
resource approach points. High/Balanced/Low expose at most 1,680/1,092/536
clumps, with shorter distance fading in cheaper profiles. Flowers share the
prevailing breeze; the existing grass and berry contact field remains intact.
Explicit white instance colors preserve the painted GLB palette when custom
instance data is enabled in Compatibility; native review caught and verified
that renderer-specific regression.
Flowers have no collision, harvesting, actor-contact deformation or saved
state. See [mesh sources and budgets](../assets/meadow/README.md).
- The blue/turquoise river follows actual centerline terrain heights, keeps
level cross-sections, and clips dry edges at the shoreline. The pool and its
outlet meet without overlapping coplanar transparent sheets. Broken foam,
subtle flowing strokes and painted reflection patches replace the hard ring
and featureless surface. These reflections are stylized procedural patches
plus ordinary sky specular response, not exact reflected scene geometry,
screen-space reflections, refraction or depth sampling. Water obeys scene
lighting at night and adds no per-frame scripts.
- MSAA follows the existing viewport ownership rules: High preserves authored
4×, Balanced caps at 2×, and Low disables it. Closing an inactive world cannot
overwrite the active world's antialiasing or an external viewport override.
The editable Blender sources live under `art/painterly/` and `art/meadow/`,
protected by `.gdignore`; runtime consumes portable GLBs. The broadleaf is
5,350 triangles / five surfaces and the conifer is 1,580 / two. All flower
clumps together are capped at 362,880 triangles / four surfaces before
quality and distance reduction. No scene collision, navigation bake, terrain
heightmap, simulation rule or save schema was changed.
`tools/capture_painterly_environment.gd -- --label=after` captures the running
main scene with paused simulation, fixed daytime presentation and hidden HUD,
including the normal gameplay camera, valley, lower village view and night.
The valley/near-village `painterly_before_*` and `painterly_after_*`
images are matched cameras; `after_gameplay` fixes the normal rig at its default
yaw/zoom with mouse input disabled, and `after_vista` is an additional review angle.
The tool also records static-camera profile samples in
`painterly_after_metrics.json`; wall-frame latency includes vsync and is not
isolated GPU time or weak-PC proof. Runtime camera defaults are unchanged.
Remaining visual work is primarily authored terrain/shoreline detail and the
blockout-like building/fortress forms. This pass improves atmosphere and
foliage without treating those assets as finished film-quality environments.
Validation for this pass: the full macOS Godot 4.7 gate passed, including all
headless scenarios, the Compatibility quality scenario, and 182 GUT tests /
2,279 assertions. Main-scene headless startup passed the gate's existing exact
diagnostic allowlist. Native Metal daytime/gameplay/valley/night images were
reviewed at 1600×900, with a separate native Compatibility capture confirming
colored petals after the instance-buffer fix. The presentation-quality scenario
also passed with native Compatibility, including white instance-color readback
(the headless dummy renderer cannot provide that readback).
The static M1 Max sample reports approximately 16.7 ms
median wall-frame time in all three profiles; see its JSON for p95, draw calls
and primitives. This includes vsync and does not establish GPU headroom.
Native renderer shutdown still reports the particle shader/material RID
retention previously documented in the storybook/foliage pass; the headless
allowlist was not expanded. Compatibility has different color/tonemapping and
its existing particle/texture shutdown diagnostics. Metal is the color
reference for the saved art-review images.
The native multi-world quality scenario additionally logged a transient macOS
OpenGL cubemap-unloadable diagnostic; the final main-scene captures did not.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1006 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1008 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 997 KiB

@@ -0,0 +1,13 @@
{
"active_trail_texels": 145,
"controller_update_p50_ms": 0.307,
"controller_update_p95_ms": 0.338,
"draw_calls": 582.0,
"note": "CPU timing includes scanning, field painting and material uploads; not isolated GPU frame time.",
"profile": "High",
"renderer": "metal",
"resolution": "1600x900",
"simulation_checksum_unchanged": true,
"trail_texture_bytes": 262144,
"workload": "Actual Jajce meadow; 8 nearest actors, player presentation follows an 11 m sweep"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1000 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

@@ -0,0 +1,53 @@
{
"device": "Apple M1 Max (Apple7)",
"renderer": "metal",
"resolution": "1600x900",
"samples": [
{
"active_npcs": 6,
"draw_calls": 825.0,
"meadow": {
"flower_clump_count": 1680,
"multimesh_batch_count": 4,
"visible_flower_clump_count": 1680
},
"msaa_enum": 2,
"primitives": 1070038.0,
"profile": "high",
"render_scale": 1.0,
"wall_frame_p50_ms": 16.649,
"wall_frame_p95_ms": 17.327
},
{
"active_npcs": 6,
"draw_calls": 710.0,
"meadow": {
"flower_clump_count": 1680,
"multimesh_batch_count": 4,
"visible_flower_clump_count": 1092
},
"msaa_enum": 1,
"primitives": 810830.0,
"profile": "balanced",
"render_scale": 0.850000023841858,
"wall_frame_p50_ms": 16.672,
"wall_frame_p95_ms": 17.06
},
{
"active_npcs": 6,
"draw_calls": 657.0,
"meadow": {
"flower_clump_count": 1680,
"multimesh_batch_count": 4,
"visible_flower_clump_count": 536
},
"msaa_enum": 0,
"primitives": 645010.0,
"profile": "low",
"render_scale": 0.699999988079071,
"wall_frame_p50_ms": 16.676,
"wall_frame_p95_ms": 17.069
}
],
"workload": "Paused main scene, static valley camera; wall time includes vsync"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 709 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 815 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

@@ -0,0 +1,53 @@
{
"device": "Apple M1 Max",
"renderer": "opengl3",
"resolution": "1600x900",
"samples": [
{
"active_npcs": 6,
"draw_calls": 1304.0,
"meadow": {
"flower_clump_count": 1680,
"multimesh_batch_count": 4,
"visible_flower_clump_count": 1680
},
"msaa_enum": 2,
"primitives": 1070038.0,
"profile": "high",
"render_scale": 1.0,
"wall_frame_p50_ms": 22.166,
"wall_frame_p95_ms": 23.467
},
{
"active_npcs": 6,
"draw_calls": 1166.0,
"meadow": {
"flower_clump_count": 1680,
"multimesh_batch_count": 4,
"visible_flower_clump_count": 1092
},
"msaa_enum": 1,
"primitives": 810758.0,
"profile": "balanced",
"render_scale": 0.850000023841858,
"wall_frame_p50_ms": 17.903,
"wall_frame_p95_ms": 26.363
},
{
"active_npcs": 6,
"draw_calls": 1084.0,
"meadow": {
"flower_clump_count": 1680,
"multimesh_batch_count": 4,
"visible_flower_clump_count": 536
},
"msaa_enum": 0,
"primitives": 645010.0,
"profile": "low",
"render_scale": 0.699999988079071,
"wall_frame_p50_ms": 13.533,
"wall_frame_p95_ms": 16.623
}
],
"workload": "Paused main scene, static valley camera; wall time includes vsync"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 469 KiB

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"] [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"] [sub_resource type="StandardMaterial3D" id="Material_sword"]
albedo_color = Color(0.72, 0.74, 0.78, 1) albedo_color = Color(0.72, 0.74, 0.78, 1)
roughness = 0.32 roughness = 0.32
@@ -85,67 +20,29 @@ top_radius = 0.045
bottom_radius = 0.045 bottom_radius = 0.045
height = 0.16 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"] [node name="PlayerVisual" type="Node3D"]
script = ExtResource("1_visual") script = ExtResource("1_visual")
[node name="Body" type="MeshInstance3D" parent="."] [node name="Body" type="MeshInstance3D" parent="."]
position = Vector3(0, 1.05, 0) 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="."] [node name="Head" type="MeshInstance3D" parent="."]
position = Vector3(0, 1.68, 0) position = Vector3(0, 1.68, 0)
mesh = SubResource("Mesh_head")
[node name="Hair" type="MeshInstance3D" parent="."] [node name="Hair" type="MeshInstance3D" parent="."]
position = Vector3(0, 1.9, -0.015) position = Vector3(0, 1.9, -0.015)
mesh = SubResource("Mesh_hair")
[node name="ArmLeft" type="MeshInstance3D" parent="."] [node name="ArmLeft" type="MeshInstance3D" parent="."]
position = Vector3(-0.39, 1.08, 0) position = Vector3(-0.39, 1.3, 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")
[node name="ArmRight" type="MeshInstance3D" parent="."] [node name="ArmRight" type="MeshInstance3D" parent="."]
position = Vector3(0.39, 1.08, 0) position = Vector3(0.39, 1.3, 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")
[node name="LegLeft" type="MeshInstance3D" parent="."] [node name="LegLeft" type="MeshInstance3D" parent="."]
position = Vector3(-0.18, 0.43, 0) position = Vector3(-0.18, 0.61, 0)
mesh = SubResource("Mesh_leg")
[node name="Foot" type="MeshInstance3D" parent="LegLeft"]
position = Vector3(0, -0.28, 0.09)
mesh = SubResource("Mesh_foot")
[node name="LegRight" type="MeshInstance3D" parent="."] [node name="LegRight" type="MeshInstance3D" parent="."]
position = Vector3(0.18, 0.43, 0) position = Vector3(0.18, 0.61, 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")
[node name="Sword" type="Node3D" parent="."] [node name="Sword" type="Node3D" parent="."]
position = Vector3(0.52, 1.22, 0.12) position = Vector3(0.52, 1.22, 0.12)
@@ -158,3 +55,6 @@ mesh = SubResource("Mesh_blade")
[node name="Grip" type="MeshInstance3D" parent="Sword"] [node name="Grip" type="MeshInstance3D" parent="Sword"]
position = Vector3(0, -0.05, 0) position = Vector3(0, -0.05, 0)
mesh = SubResource("Mesh_grip") 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 navigation_failed(sim_id: int)
signal position_changed(sim_id: int, active_position: Vector3) 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 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 @export var debug_logs := false
@@ -76,7 +76,7 @@ func _process(delta: float) -> void:
return return
glyph_phase = fmod(glyph_phase + delta * 2.2, TAU) glyph_phase = fmod(glyph_phase + delta * 2.2, TAU)
if task_glyph_root.visible: 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 task_glyph_root.rotation.y = glyph_phase * 0.35
if opportunity_concern_root.visible: if opportunity_concern_root.visible:
concern_phase = fmod(concern_phase + delta * 1.65, TAU) 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: func _apply_personal_details() -> void:
var hair_colors := [ AnimalAppearance.apply_to(self, sim_id, true)
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
func _apply_profession_presentation() -> void: func _apply_profession_presentation() -> void:
@@ -142,12 +133,7 @@ func _apply_profession_presentation() -> void:
profession_prop.visible = false profession_prop.visible = false
return return
var body_material := StandardMaterial3D.new() AnimalAppearance.set_profession_color(self, definition.visual_color)
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
var prop_material := StandardMaterial3D.new() var prop_material := StandardMaterial3D.new()
prop_material.albedo_color = definition.prop_color 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"] [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="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"] [sub_resource type="StandardMaterial3D" id="StandardMaterial3D_food"]
albedo_color = Color(0.38, 0.22, 0.08, 1) albedo_color = Color(0.38, 0.22, 0.08, 1)
roughness = 0.9 roughness = 0.9
@@ -139,62 +78,29 @@ shape = SubResource("CapsuleShape3D_d844k")
[node name="MeshInstance3D" type="MeshInstance3D" parent="."] [node name="MeshInstance3D" type="MeshInstance3D" parent="."]
position = Vector3(0, 1.05, 0) position = Vector3(0, 1.05, 0)
mesh = SubResource("CylinderMesh_body")
[node name="Head" type="MeshInstance3D" parent="."] [node name="Head" type="MeshInstance3D" parent="."]
position = Vector3(0, 1.68, 0) position = Vector3(0, 1.68, 0)
mesh = SubResource("SphereMesh_head")
[node name="Hair" type="MeshInstance3D" parent="."] [node name="Hair" type="MeshInstance3D" parent="."]
position = Vector3(0, 1.9, -0.015) 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="."] [node name="ArmLeft" type="MeshInstance3D" parent="."]
position = Vector3(-0.39, 1.08, 0) position = Vector3(-0.39, 1.3, 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")
[node name="ArmRight" type="MeshInstance3D" parent="."] [node name="ArmRight" type="MeshInstance3D" parent="."]
position = Vector3(0.39, 1.08, 0) position = Vector3(0.39, 1.3, 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")
[node name="LegLeft" type="MeshInstance3D" parent="."] [node name="LegLeft" type="MeshInstance3D" parent="."]
position = Vector3(-0.18, 0.43, 0) position = Vector3(-0.18, 0.61, 0)
mesh = SubResource("CapsuleMesh_leg")
[node name="Foot" type="MeshInstance3D" parent="LegLeft"]
position = Vector3(0, -0.28, 0.09)
mesh = SubResource("BoxMesh_foot")
[node name="LegRight" type="MeshInstance3D" parent="."] [node name="LegRight" type="MeshInstance3D" parent="."]
position = Vector3(0.18, 0.43, 0) position = Vector3(0.18, 0.61, 0)
mesh = SubResource("CapsuleMesh_leg")
[node name="Foot" type="MeshInstance3D" parent="LegRight"]
position = Vector3(0, -0.28, 0.09)
mesh = SubResource("BoxMesh_foot")
[node name="ProfessionProp" type="MeshInstance3D" parent="."] [node name="ProfessionProp" type="MeshInstance3D" parent="."]
[node name="ProfessionLabel" type="Label3D" parent="."] [node name="ProfessionLabel" type="Label3D" parent="."]
position = Vector3(0, 2.05, 0) position = Vector3(0, 2.8, 0)
billboard = 1 billboard = 1
no_depth_test = true no_depth_test = true
text = "Villager" text = "Villager"
@@ -223,13 +129,13 @@ mesh = SubResource("CylinderMesh_wood")
[node name="TaskGlyphRoot" type="Node3D" parent="."] [node name="TaskGlyphRoot" type="Node3D" parent="."]
visible = false visible = false
position = Vector3(0, 2.35, 0) position = Vector3(0, 2.9, 0)
[node name="TaskGlyph" type="MeshInstance3D" parent="TaskGlyphRoot"] [node name="TaskGlyph" type="MeshInstance3D" parent="TaskGlyphRoot"]
[node name="OpportunityConcernRoot" type="Node3D" parent="."] [node name="OpportunityConcernRoot" type="Node3D" parent="."]
visible = false visible = false
position = Vector3(-0.4, 2.72, 0) position = Vector3(-0.4, 3.25, 0)
[node name="EmptyBowl" type="MeshInstance3D" parent="OpportunityConcernRoot"] [node name="EmptyBowl" type="MeshInstance3D" parent="OpportunityConcernRoot"]
rotation_degrees = Vector3(72, 0, 0) rotation_degrees = Vector3(72, 0, 0)
@@ -250,7 +156,7 @@ mesh = SubResource("SphereMesh_concern")
[node name="TrustReactionRoot" type="Node3D" parent="."] [node name="TrustReactionRoot" type="Node3D" parent="."]
visible = false visible = false
position = Vector3(0, 2.72, 0) position = Vector3(0, 3.25, 0)
scale = Vector3(0.24, 0.24, 0.24) scale = Vector3(0.24, 0.24, 0.24)
[node name="Halo" type="MeshInstance3D" parent="TrustReactionRoot"] [node name="Halo" type="MeshInstance3D" parent="TrustReactionRoot"]
@@ -278,3 +184,6 @@ path_desired_distance = 0.3
target_desired_distance = 0.5 target_desired_distance = 0.5
height = 1.7 height = 1.7
radius = 0.4 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: func _ready() -> void:
player_body = get_parent() as CharacterBody3D player_body = get_parent() as CharacterBody3D
AnimalAppearance.apply_to(self, 0)
func _process(delta: float) -> void: func _process(delta: float) -> void:
+4
View File
@@ -69,3 +69,7 @@ dash={
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":true,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194325,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":true,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194325,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
] ]
} }
[rendering]
anti_aliasing/quality/msaa_3d=2
+210
View File
@@ -0,0 +1,210 @@
extends SceneTree
const Controller := preload("res://world/jajce/GrassInteractionController.gd")
var failures: Array[String] = []
class RestoreSource:
extends Node
signal state_restored
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
_check_trail_lifecycle()
await _check_loaded_binding()
if failures.is_empty():
print("Foliage interaction checks passed")
quit(0)
else:
for failure in failures:
push_error(failure)
quit(1)
func _check_trail_lifecycle() -> void:
var field := FoliageTrailMap.new()
var positions: Dictionary[int, Vector3] = {1: Vector3(0.25, 0, 0.25)}
field.advance(0.08, Vector3.ZERO, positions)
positions[1] = Vector3(2.25, 0, 0.25)
field.advance(0.08, Vector3.ZERO, positions)
_check(_sample(field, Vector2(1.25, 0.25)).b > 0.9, "Walking should leave a continuous trail")
_check(
_sample(field, Vector2(1.25, 0.75)).g > 0 and _sample(field, Vector2(1.25, -0.25)).g < 0,
"The two sides of a trail should bend away from the travelled line"
)
positions[1] = Vector3(18.25, 0, 0.25)
field.advance(0.08, Vector3.ZERO, positions)
_check(_sample(field, Vector2(10.25, 0.25)).b == 0, "Teleports must not paint connecting lines")
_check(
_sample(field, Vector2(18.25, 0.25)).b > 0.9,
"Teleported actors still press their destination"
)
field.advance(0.08, Vector3(1, 0, 1), {})
_check(
_sample(field, Vector2(1.25, 0.25)).b > 0.8,
"Camera movement must keep trails in world space"
)
field.advance(4.0, Vector3.ZERO, {})
var recovering := _sample(field, Vector2(1.25, 0.25)).b
_check(recovering > 0.1 and recovering < 0.6, "Abandoned trails should gradually recover")
field.advance(4.1, Vector3.ZERO, {})
_check(
(field.get("_cells") as Dictionary).is_empty(),
"Recovered trails should release sparse history"
)
positions[1] = Vector3(0.25, 6, 0.25)
field.advance(0.08, Vector3.ZERO, positions)
var elevated := _sample(field, Vector2(0.25, 0.25))
_check(
is_equal_approx(elevated.a / elevated.b, 6.0),
"Foot height must reach the shader for bridge rejection"
)
field.advance(0.08, Vector3(300, 0, 300), {})
_check((field.get("_cells") as Dictionary).is_empty(), "Off-window trails must be discarded")
positions[1] = Vector3(2.25, 0, 0.25)
field.advance(0.08, Vector3.ZERO, positions)
_check(
_sample(field, Vector2(1.25, 0.25)).b == 0,
"Re-entering actors must not connect to stale positions"
)
field.clear()
_check(_sample(field, Vector2(2.25, 0.25)).b == 0, "Reset must clear the uploaded field")
_check(
(field.get("_previous_positions") as Dictionary).is_empty(),
"Reset must discard actor history"
)
func _check_loaded_binding() -> void:
var stage := Node3D.new()
root.add_child(stage)
var camera := Camera3D.new()
stage.add_child(camera)
camera.current = true
var simulation := RestoreSource.new()
simulation.name = "SimulationManager"
stage.add_child(simulation)
var world := Node3D.new()
stage.add_child(world)
var terrain := Node3D.new()
world.add_child(terrain)
var patch := (
load("res://world/jajce/StylizedBerryPatch.tscn").instantiate() as StylizedBerryPatch
)
world.add_child(patch)
patch.scale = Vector3(1.2, 0.8, 1.1)
patch.rotation.y = 0.6
var controller := Controller.new()
controller.grass_material = (
load("res://world/jajce/materials/cozy_grass_material.tres").duplicate(true)
)
terrain.add_child(controller)
var foreign := Node3D.new()
root.add_child(foreign)
foreign.add_to_group("grass_interactors")
var actors: Array[Node3D] = []
for index in 12:
var actor := Node3D.new()
stage.add_child(actor)
actor.position.x = 1.0 + index
actor.add_to_group("grass_interactors")
actors.append(actor)
await process_frame
controller.set_process(false)
controller.call("_refresh_interactors", 0.08)
var material := controller.grass_material
var positions: PackedVector3Array = material.get_shader_parameter("interactor_positions")
_check(
int(material.get_shader_parameter("interactor_count")) == 8,
"Contact work must respect the eight-actor cap"
)
_check(
positions[0] == actors[0].position,
"A closer actor in another scene container must be rejected"
)
_check(
bool(material.get_shader_parameter("foliage_trails_enabled")),
"Loaded grass must enable its trail field"
)
var leaves := patch.get_node("Leaves") as MultiMeshInstance3D
var berries := patch.get_node("Berries") as MultiMeshInstance3D
var leaf_material := leaves.multimesh.mesh.surface_get_material(0) as ShaderMaterial
var berry_material := berries.multimesh.mesh.surface_get_material(0) as ShaderMaterial
_check(
(
(
leaf_material.get_shader_parameter("foliage_trail_map")
== material.get_shader_parameter("foliage_trail_map")
)
and (
berry_material.get_shader_parameter("foliage_trail_map")
== material.get_shader_parameter("foliage_trail_map")
)
),
"Bush leaves, berries and meadow must use the same world-local footprint field"
)
var visible_berries := patch.get_visible_berry_count()
actors[0].hide()
controller.primary_interactor_path = controller.get_path_to(actors.back())
controller.max_interactors = 4
controller.call("_refresh_interactors", 0.12)
positions = material.get_shader_parameter("interactor_positions")
_check(
(
int(material.get_shader_parameter("interactor_count")) == 4
and positions[0].x == 12
and positions[1].x == 2
),
"Balanced must reserve the primary actor slot, cap actors and ignore hidden visuals"
)
simulation.state_restored.emit()
_check(
not bool(material.get_shader_parameter("foliage_trails_enabled")),
"Successful restores must clear visual trails immediately"
)
controller.set_interaction_enabled(false)
_check(not controller.is_processing(), "Low quality must stop interaction updates")
_check(
not bool(leaf_material.get_shader_parameter("foliage_trails_enabled")),
"Low quality must release bushes as well as grass"
)
_check(
int(material.get_shader_parameter("interactor_count")) == 0,
"Low quality must clear direct contacts"
)
_check(
patch.get_visible_berry_count() == visible_berries,
"Contact bending must not change resource presentation amounts"
)
controller.set_interaction_enabled(true)
controller.call("_refresh_interactors", 0.08)
_check(
bool(berry_material.get_shader_parameter("foliage_trails_enabled")),
"Bush interaction must resume after Low quality"
)
var other_field := FoliageTrailMap.new()
_check(
other_field.texture != material.get_shader_parameter("foliage_trail_map"),
"Each world must own a distinct trail texture"
)
foreign.free()
stage.free()
await process_frame
func _sample(field: FoliageTrailMap, position: Vector2) -> Color:
var image := field.get("_image") as Image
var pixel := Vector2i(
(position - Vector2(field.world_rect.x, field.world_rect.y)) / field.CELL_SIZE
)
return image.get_pixelv(pixel)
func _check(condition: bool, message: String) -> void:
if not condition:
failures.append(message)
+1
View File
@@ -0,0 +1 @@
uid://clvjg2p81ay3b
+131 -11
View File
@@ -8,6 +8,8 @@ func _initialize() -> void:
func _run() -> void: func _run() -> void:
var viewport := root as Viewport
var original_msaa := viewport.msaa_3d
var camera := Camera3D.new() var camera := Camera3D.new()
camera.current = true camera.current = true
root.add_child(camera) root.add_child(camera)
@@ -16,13 +18,14 @@ func _run() -> void:
await process_frame await process_frame
await physics_frame await physics_frame
var viewport := root as Viewport
var environment: Environment = ( var environment: Environment = (
(world.get_node("WorldEnvironment") as WorldEnvironment).environment (world.get_node("WorldEnvironment") as WorldEnvironment).environment
) )
var light := world.get_node("DirectionalLight3D") as DirectionalLight3D var light := world.get_node("DirectionalLight3D") as DirectionalLight3D
var grass := world.get_node("TerrainRoot/Terrain3D/CozyGrassField") as Node3D var grass := world.get_node("TerrainRoot/Terrain3D/CozyGrassField") as Node3D
var grass_controller := world.get_node("TerrainRoot/GrassInteractionController") as Node var grass_controller := world.get_node("TerrainRoot/GrassInteractionController") as Node
var meadow := world.get_node("PainterlyMeadow") as PainterlyMeadow
var balanced_meadow := meadow.get_presentation_stats()
var world_grass_material := grass.get("mesh_material_override") as ShaderMaterial var world_grass_material := grass.get("mesh_material_override") as ShaderMaterial
var world_process_material := grass.get("process_material") as ShaderMaterial var world_process_material := grass.get("process_material") as ShaderMaterial
var world_sky: Sky = environment.sky var world_sky: Sky = environment.sky
@@ -41,6 +44,10 @@ func _run() -> void:
+ " (found %.2f)" % balanced_render_scale + " (found %.2f)" % balanced_render_scale
) )
) )
_check(
viewport.msaa_3d == mini(original_msaa, Viewport.MSAA_2X),
"Balanced should cap authored multisample antialiasing at 2x"
)
_check( _check(
( (
light.directional_shadow_max_distance <= JajceWorld.BALANCED_SHADOW_DISTANCE light.directional_shadow_max_distance <= JajceWorld.BALANCED_SHADOW_DISTANCE
@@ -54,6 +61,14 @@ func _run() -> void:
world_grass_material == grass_controller.get("grass_material"), world_grass_material == grass_controller.get("grass_material"),
"Grass rendering and interaction should share this world's isolated material" "Grass rendering and interaction should share this world's isolated material"
) )
_check(
(
int(balanced_meadow["multimesh_batch_count"]) == 4
and int(balanced_meadow["flower_clump_count"]) in range(1, 1801)
and _meadow_has_finite_placements(meadow)
),
"Meadow batches should retain neutral instance colors, finite placement and their clump budget"
)
_check( _check(
world.apply_presentation_quality(JajceWorld.PresentationQuality.HIGH), world.apply_presentation_quality(JajceWorld.PresentationQuality.HIGH),
@@ -63,6 +78,21 @@ func _run() -> void:
var high_particle_count := int(grass.get("particle_count")) var high_particle_count := int(grass.get("particle_count"))
var high_render_scale := viewport.scaling_3d_scale var high_render_scale := viewport.scaling_3d_scale
var high_shadow_distance := light.directional_shadow_max_distance var high_shadow_distance := light.directional_shadow_max_distance
_check(
viewport.msaa_3d == original_msaa,
"High should restore the authored multisample antialiasing setting"
)
var high_meadow := meadow.get_presentation_stats()
_check(
(
int(high_meadow["visible_flower_clump_count"]) == int(high_meadow["flower_clump_count"])
and (
int(balanced_meadow["visible_flower_clump_count"])
< int(high_meadow["visible_flower_clump_count"])
)
),
"High should restore every flower clump while Balanced reduces meadow density"
)
_check( _check(
high_particle_count in range(9000, 15001), high_particle_count in range(9000, 15001),
"High should restore the authored grass population" "High should restore the authored grass population"
@@ -94,6 +124,9 @@ func _run() -> void:
viewport.scaling_3d_scale <= JajceWorld.LOW_RENDER_SCALE + 0.001, viewport.scaling_3d_scale <= JajceWorld.LOW_RENDER_SCALE + 0.001,
"Low should reduce 3D render resolution" "Low should reduce 3D render resolution"
) )
_check(
viewport.msaa_3d == Viewport.MSAA_DISABLED, "Low should disable multisample antialiasing"
)
_check( _check(
( (
environment.fog_enabled environment.fog_enabled
@@ -120,6 +153,18 @@ func _run() -> void:
) )
_check(not grass_controller.is_processing(), "Low should stop grass interaction scans") _check(not grass_controller.is_processing(), "Low should stop grass interaction scans")
_check(_not_any_grass_particle_emitting(grass), "Low should stop every grass particle emitter") _check(_not_any_grass_particle_emitting(grass), "Low should stop every grass particle emitter")
var low_meadow := meadow.get_presentation_stats()
_check(
(
int(low_meadow["visible_flower_clump_count"]) > 0
and (
int(low_meadow["visible_flower_clump_count"])
< int(balanced_meadow["visible_flower_clump_count"])
)
and low_meadow["flower_clump_count"] == high_meadow["flower_clump_count"]
),
"Low should retain a reduced flower population without regenerating placements"
)
_check( _check(
( (
world_grass_material != null world_grass_material != null
@@ -136,6 +181,7 @@ func _run() -> void:
_check( _check(
( (
is_equal_approx(viewport.scaling_3d_scale, high_render_scale) is_equal_approx(viewport.scaling_3d_scale, high_render_scale)
and viewport.msaa_3d == original_msaa
and environment.glow_enabled and environment.glow_enabled
and (environment.volumetric_fog_enabled == world.call("_supports_volumetric_fog")) and (environment.volumetric_fog_enabled == world.call("_supports_volumetric_fog"))
and environment.adjustment_enabled and environment.adjustment_enabled
@@ -153,12 +199,19 @@ func _run() -> void:
), ),
"Returning to High should restore grass rendering and processing" "Returning to High should restore grass rendering and processing"
) )
_check(
meadow.get_presentation_stats() == high_meadow,
"Returning to High should restore the same complete flower batches"
)
var world_base_scale := float(world.get("_authored_render_scale")) var world_base_scale := float(world.get("_authored_render_scale"))
world.free() world.free()
_check( _check(
is_equal_approx(viewport.scaling_3d_scale, world_base_scale), (
"An exiting world should restore the viewport scale it originally owned" is_equal_approx(viewport.scaling_3d_scale, world_base_scale)
and viewport.msaa_3d == original_msaa
),
"An exiting world should restore the viewport scale and antialiasing it originally owned"
) )
_check( _check(
( (
@@ -183,6 +236,7 @@ func _run() -> void:
_check( _check(
( (
fresh_world.get_active_presentation_quality() == JajceWorld.PresentationQuality.BALANCED fresh_world.get_active_presentation_quality() == JajceWorld.PresentationQuality.BALANCED
and viewport.msaa_3d == mini(original_msaa, Viewport.MSAA_2X)
and fresh_environment.fog_enabled and fresh_environment.fog_enabled
and not fresh_environment.volumetric_fog_enabled and not fresh_environment.volumetric_fog_enabled
and fresh_grass.visible and fresh_grass.visible
@@ -205,8 +259,11 @@ func _run() -> void:
var original_scale := float(fresh_world.get("_authored_render_scale")) var original_scale := float(fresh_world.get("_authored_render_scale"))
fresh_world.free() fresh_world.free()
_check( _check(
is_equal_approx(viewport.scaling_3d_scale, original_scale), (
"A sequential world should release its viewport-scale ownership" is_equal_approx(viewport.scaling_3d_scale, original_scale)
and viewport.msaa_3d == original_msaa
),
"A sequential world should release its viewport scale and antialiasing ownership"
) )
var older_owner := _create_presentation_only_world(JajceWorld.PresentationQuality.LOW) var older_owner := _create_presentation_only_world(JajceWorld.PresentationQuality.LOW)
@@ -216,19 +273,29 @@ func _run() -> void:
root.add_child(newer_owner) root.add_child(newer_owner)
await process_frame await process_frame
_check( _check(
is_equal_approx(float(newer_owner.get("_authored_render_scale")), original_scale), (
"Concurrent owners should inherit the viewport base, not another owner's reduced scale" is_equal_approx(float(newer_owner.get("_authored_render_scale")), original_scale)
and int(newer_owner.get("_authored_msaa")) == original_msaa
),
"Concurrent owners should inherit the viewport's base scale and antialiasing"
) )
older_owner.free() older_owner.free()
_check( _check(
is_equal_approx(viewport.scaling_3d_scale, JajceWorld.BALANCED_RENDER_SCALE), (
"A stale owner exiting should not overwrite the active owner's scale" is_equal_approx(viewport.scaling_3d_scale, JajceWorld.BALANCED_RENDER_SCALE)
and viewport.msaa_3d == mini(original_msaa, Viewport.MSAA_2X)
),
"A stale owner exiting should preserve the active owner's scale and antialiasing"
) )
newer_owner.free() newer_owner.free()
_check( _check(
is_equal_approx(viewport.scaling_3d_scale, original_scale), (
"The final viewport owner should restore the original scale on exit" is_equal_approx(viewport.scaling_3d_scale, original_scale)
and viewport.msaa_3d == original_msaa
),
"The final viewport owner should restore the original scale and antialiasing on exit"
) )
await _check_msaa_previous_owner_and_external_change(viewport)
var main_scene: Node = load("res://main.tscn").instantiate() var main_scene: Node = load("res://main.tscn").instantiate()
var time_dial := main_scene.get_node("UI/TimeDial") as Control var time_dial := main_scene.get_node("UI/TimeDial") as Control
@@ -264,6 +331,32 @@ func _all_grass_particles_emitting(grass: Node3D) -> bool:
return true return true
func _meadow_has_finite_placements(meadow: PainterlyMeadow) -> bool:
var terrain := meadow.get_node(meadow.terrain_path) as Terrain3D
# The dummy headless renderer returns black for every instance-color readback.
var can_read_instance_colors := DisplayServer.get_name() != "headless"
for child in meadow.get_children():
var batch := child as MultiMeshInstance3D
if batch == null or batch.multimesh == null:
return false
if not batch.multimesh.use_colors or not batch.multimesh.use_custom_data:
return false
for index in batch.multimesh.instance_count:
if (
can_read_instance_colors
and not batch.multimesh.get_instance_color(index).is_equal_approx(Color.WHITE)
):
return false
var placement := batch.multimesh.get_instance_transform(index)
if not placement.is_finite():
return false
var point := batch.to_global(placement.origin)
var height := terrain.data.get_height(point)
if is_nan(height) or absf(point.y - height) > 0.025:
return false
return true
func _create_presentation_only_world(quality: int) -> JajceWorld: func _create_presentation_only_world(quality: int) -> JajceWorld:
var world: JajceWorld = load("res://world/jajce/JajceWorld.tscn").instantiate() var world: JajceWorld = load("res://world/jajce/JajceWorld.tscn").instantiate()
# Stable simulation-facing IDs deliberately allow only one loaded authority # Stable simulation-facing IDs deliberately allow only one loaded authority
@@ -273,6 +366,33 @@ func _create_presentation_only_world(quality: int) -> JajceWorld:
return world return world
func _check_msaa_previous_owner_and_external_change(viewport: Viewport) -> void:
var original_msaa := viewport.msaa_3d
viewport.msaa_3d = Viewport.MSAA_8X
var previous_owner := _create_presentation_only_world(JajceWorld.PresentationQuality.LOW)
root.add_child(previous_owner)
await process_frame
var active_owner := _create_presentation_only_world(JajceWorld.PresentationQuality.HIGH)
root.add_child(active_owner)
await process_frame
_check(
viewport.msaa_3d == Viewport.MSAA_8X,
"A High owner should inherit authored antialiasing even when the previous owner uses Low"
)
active_owner.free()
_check(
viewport.msaa_3d == Viewport.MSAA_DISABLED,
"An exiting active owner should restore the surviving previous owner's antialiasing"
)
viewport.msaa_3d = Viewport.MSAA_2X
previous_owner.free()
_check(
viewport.msaa_3d == Viewport.MSAA_2X,
"Releasing the final owner should preserve an external antialiasing change"
)
viewport.msaa_3d = original_msaa
func _not_any_grass_particle_emitting(grass: Node3D) -> bool: func _not_any_grass_particle_emitting(grass: Node3D) -> bool:
var particles: Array = grass.get("particle_nodes") var particles: Array = grass.get("particle_nodes")
for value in particles: for value in particles:
@@ -20,6 +20,7 @@ func _run() -> void:
var manager = main_scene.get_node("SimulationManager") var manager = main_scene.get_node("SimulationManager")
manager.set_process(false) manager.set_process(false)
_check_village_tree_bindings(main_scene, manager)
var cluster := main_scene.get_node(CLUSTER_PATH) as RiverbankResourceCluster var cluster := main_scene.get_node(CLUSTER_PATH) as RiverbankResourceCluster
var berry := cluster.get_node("ResourceAnchors/BerryBush_River_02") as ResourceNode var berry := cluster.get_node("ResourceAnchors/BerryBush_River_02") as ResourceNode
var tree := cluster.get_node("ResourceAnchors/Tree_River_Resource_01") as ResourceNode var tree := cluster.get_node("ResourceAnchors/Tree_River_Resource_01") as ResourceNode
@@ -120,6 +121,34 @@ func _run() -> void:
_finish() _finish()
func _check_village_tree_bindings(main_scene: Node, manager: Node) -> void:
var snapshot: String = manager.serialize_state()
for tree_name in ["Tree_01", "Tree_02", "Tree_North_Outskirts_01", "Tree_South_Edge_01"]:
var tree := (
main_scene.get_node("JajceWorld/WorldObjects/ResourceNodes/" + tree_name)
as ResourceNode
)
var visual := tree.get_node("Visual/TreePresentation") as HarvestableTreePresentation
_check(
visual.resource_node == tree,
"Village tree art should bind the existing finite resource"
)
while tree.state.can_extract():
manager.harvest_resource_node(tree)
_check(visual.is_stump_visible(), "Harvesting the real village tree should leave a stump")
_check(manager.restore_state_from_json(snapshot), "Village tree harvest review should restore")
for tree_name in ["Tree_01", "Tree_02", "Tree_North_Outskirts_01", "Tree_South_Edge_01"]:
var visual := (
main_scene.get_node(
"JajceWorld/WorldObjects/ResourceNodes/" + tree_name + "/Visual/TreePresentation"
)
as HarvestableTreePresentation
)
_check(
visual.get_visible_canopy_count() == 4, "Restore rebuilds each full village tree crown"
)
func _check_initial_presentation( func _check_initial_presentation(
berry_visual: StylizedBerryPatch, tree_visual: HarvestableTreePresentation berry_visual: StylizedBerryPatch, tree_visual: HarvestableTreePresentation
) -> void: ) -> void:
+1 -1
View File
@@ -535,7 +535,7 @@ func _run() -> void:
( (
main_scene.has_node("Player/Visual/Body") main_scene.has_node("Player/Visual/Body")
and main_scene.has_node("Player/Visual/Head") 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" "Player should use the same readable multi-part visual language as villagers"
) )
+85 -7
View File
@@ -63,10 +63,13 @@ func _run() -> void:
var river_surface := world.get_node("WaterRoot/RiverSurface") as Node3D var river_surface := world.get_node("WaterRoot/RiverSurface") as Node3D
_check_ribbon_tracks_terrain( _check_ribbon_tracks_terrain(
terrain, river_surface.get_node("RiverRibbon") as MeshInstance3D, "Downstream water" terrain, river_surface.get_node("RiverRibbon") as MeshInstance3D, "Downstream water", false
) )
_check_ribbon_tracks_terrain( _check_ribbon_tracks_terrain(
terrain, river_surface.get_node("UpperRiverRibbon") as MeshInstance3D, "Upper-stream water" terrain,
river_surface.get_node("UpperRiverRibbon") as MeshInstance3D,
"Upper-stream water",
true
) )
print( print(
@@ -83,23 +86,98 @@ func _terrain_height(terrain: Terrain3D, world_x: float, world_z: float) -> floa
func _check_ribbon_tracks_terrain( func _check_ribbon_tracks_terrain(
terrain: Terrain3D, ribbon: MeshInstance3D, label: String terrain: Terrain3D, ribbon: MeshInstance3D, label: String, is_upper: bool
) -> void: ) -> void:
_check(ribbon != null and ribbon.mesh is ArrayMesh, "%s ribbon should be generated" % label) _check(ribbon != null and ribbon.mesh is ArrayMesh, "%s ribbon should be generated" % label)
if ribbon == null or not ribbon.mesh is ArrayMesh: if ribbon == null or not ribbon.mesh is ArrayMesh:
return return
var arrays := ribbon.mesh.surface_get_arrays(0) var arrays := ribbon.mesh.surface_get_arrays(0)
var vertices: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX] var vertices: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
var uvs: PackedVector2Array = arrays[Mesh.ARRAY_TEX_UV]
_check(not vertices.is_empty(), "%s ribbon should contain vertices" % label) _check(not vertices.is_empty(), "%s ribbon should contain vertices" % label)
for vertex_index in range(0, vertices.size(), maxi(1, vertices.size() / 12)): if vertices.is_empty() or uvs.size() != vertices.size():
failures.append("%s ribbon requires a complete coordinate profile" % label)
return
var start_z := (
JajceWatercourse.UPPER_STREAM_START_Z if is_upper else JajceWatercourse.DOWNSTREAM_START_Z
)
var end_z := (
JajceWatercourse.UPPER_STREAM_END_Z if is_upper else JajceWatercourse.DOWNSTREAM_END_Z
)
_check(
(
absf(ribbon.to_global(vertices[0]).z - start_z) < 0.03
and absf(ribbon.to_global(vertices[-1]).z - end_z) < 0.03
),
"%s ribbon should span its entire authored stream length" % label
)
var pool := ribbon.get_parent().get_node("PlungePool") as MeshInstance3D
var pool_mesh := pool.mesh as PlaneMesh
var pool_radii := pool_mesh.size * Vector2(pool.scale.x, pool.scale.z) * 0.5
var min_depth := INF
var max_depth := -INF
for vertex_index in vertices.size():
var world_vertex := ribbon.to_global(vertices[vertex_index]) var world_vertex := ribbon.to_global(vertices[vertex_index])
var world_z := lerpf(start_z, end_z, uvs[vertex_index].y)
var center_x := (
JajceWatercourse.upper_stream_center_x(world_z)
if is_upper
else JajceWatercourse.downstream_center_x(world_z)
)
var half_width := (
JajceWatercourse.upper_stream_half_width(world_z)
if is_upper
else JajceWatercourse.downstream_half_width(world_z)
)
if not is_upper:
var pool_progress := (world_z - pool.global_position.z) / pool_radii.y
if absf(pool_progress) < 1.0:
half_width = maxf(
half_width, pool_radii.x * sqrt(1.0 - pool_progress * pool_progress)
)
var center_height := _terrain_height(terrain, center_x, world_z)
var terrain_height := terrain.data.get_height(world_vertex) var terrain_height := terrain.data.get_height(world_vertex)
if is_nan(terrain_height): if (
not world_vertex.is_finite()
or not is_finite(terrain_height)
or not is_finite(center_height)
):
failures.append("%s ribbon sampled outside Terrain3D" % label) failures.append("%s ribbon sampled outside Terrain3D" % label)
return return
if absf(world_vertex.y - terrain_height - JajceWatercourse.WATER_SURFACE_OFFSET) > 0.03: if absf(world_vertex.y - center_height - JajceWatercourse.WATER_SURFACE_OFFSET) > 0.03:
failures.append("%s ribbon should hug the carved terrain" % label) failures.append("%s cross-section should follow its actual carved centerline" % label)
return return
if (
absf(world_vertex.z - world_z) > 0.03
or absf(world_vertex.x - center_x) > half_width + 0.03
):
failures.append(
"%s ribbon should stay inside the authored stream/pool footprint" % label
)
return
var depth := world_vertex.y - terrain_height
min_depth = minf(min_depth, depth)
max_depth = maxf(max_depth, depth)
if depth < -0.03 or depth > JajceWatercourse.WATER_SURFACE_OFFSET + 0.03:
failures.append("%s surface should remain shallow and above the channel bed" % label)
return
var across := uvs[vertex_index].x
if is_zero_approx(across) or is_equal_approx(across, 1.0):
var edge_distance := absf(world_vertex.x - center_x)
if absf(edge_distance - half_width) > 0.03 and absf(depth) > 0.03:
failures.append(
"%s edges must reach the authored extent or the actual shoreline" % label
)
return
if (
(is_zero_approx(across) and world_vertex.x >= center_x)
or (is_equal_approx(across, 1.0) and world_vertex.x <= center_x)
):
failures.append(
"%s water must retain its authored centerline inside both banks" % label
)
return
print("[TEST] %s depth bounds | min=%.3f max=%.3f" % [label, min_depth, max_depth])
func _check(condition: bool, message: String) -> void: func _check(condition: bool, message: String) -> void:
+51 -1
View File
@@ -89,6 +89,11 @@ func _run() -> void:
"Village center should include a few oversized ambient butterflies" "Village center should include a few oversized ambient butterflies"
) )
var grass_field := world.get_node("TerrainRoot/Terrain3D/CozyGrassField") 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_material := grass_field.get("mesh_material_override") as ShaderMaterial
var grass_process := grass_field.get("process_material") as ShaderMaterial var grass_process := grass_field.get("process_material") as ShaderMaterial
_check( _check(
@@ -112,6 +117,10 @@ func _run() -> void:
"Grass placement should stay short enough to preserve villagers and paths" "Grass placement should stay short enough to preserve villagers and paths"
) )
var grass_controller := world.get_node("TerrainRoot/GrassInteractionController") 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( _check(
grass_controller.get("grass_material") == grass_material, grass_controller.get("grass_material") == grass_material,
"Grass interaction should update the same material rendered by Terrain3D particles" "Grass interaction should update the same material rendered by Terrain3D particles"
@@ -189,7 +198,7 @@ func _run() -> void:
if not tree.has_node("Canopy"): if not tree.has_node("Canopy"):
continue continue
var canopy := tree.get_node("Canopy") as MeshInstance3D var canopy := tree.get_node("Canopy") as MeshInstance3D
var material := canopy.mesh.material as ShaderMaterial var material := canopy.get_active_material(0) as ShaderMaterial
tree_wind_is_bounded = ( tree_wind_is_bounded = (
tree_wind_is_bounded tree_wind_is_bounded
and material != null and material != null
@@ -240,6 +249,8 @@ func _run() -> void:
upper_river_ribbon.mesh is ArrayMesh, upper_river_ribbon.mesh is ArrayMesh,
"Waterfall shelf should expose a terrain-following upper stream" "Waterfall shelf should expose a terrain-following upper stream"
) )
_check_water_cross_sections(river_ribbon)
_check_water_cross_sections(upper_river_ribbon)
_check( _check(
world.has_node("WaterRoot/RiverSurface/PlungePool"), world.has_node("WaterRoot/RiverSurface/PlungePool"),
"River should widen into a readable plunge pool beneath the waterfall" "River should widen into a readable plunge pool beneath the waterfall"
@@ -261,6 +272,7 @@ func _run() -> void:
), ),
"Plunge-pool water should follow the carved basin height" "Plunge-pool water should follow the carved basin height"
) )
_check_pool_outlet(river_ribbon, plunge_pool, terrain)
var environment: Environment = ( var environment: Environment = (
(world.get_node("WorldEnvironment") as WorldEnvironment).environment (world.get_node("WorldEnvironment") as WorldEnvironment).environment
) )
@@ -619,6 +631,44 @@ func _run() -> void:
quit(1) quit(1)
func _check_water_cross_sections(ribbon: MeshInstance3D) -> void:
var arrays := ribbon.mesh.surface_get_arrays(0)
var vertices: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
var uvs: PackedVector2Array = arrays[Mesh.ARRAY_TEX_UV]
var row_height := 0.0
for index in vertices.size():
if is_zero_approx(uvs[index].x):
row_height = vertices[index].y
_check(
is_equal_approx(vertices[index].y, row_height),
"Water cross-sections should remain level instead of climbing either terrain bank"
)
func _check_pool_outlet(ribbon: MeshInstance3D, pool: MeshInstance3D, terrain: Terrain3D) -> void:
var vertices: PackedVector3Array = ribbon.mesh.surface_get_arrays(0)[Mesh.ARRAY_VERTEX]
var start := ribbon.to_global(vertices[0])
var pool_mesh := pool.mesh as PlaneMesh
var radii := pool_mesh.size * Vector2(pool.scale.x, pool.scale.z) * 0.5
var progress := (start.z - pool.global_position.z) / radii.y
var expected_edge := pool.global_position.x - radii.x * sqrt(1.0 - progress * progress)
var meets_ellipse := absf(start.x - expected_edge) < 0.03
var meets_shore := (
start.x >= expected_edge
and start.x < pool.global_position.x
and absf(start.y - terrain.data.get_height(start)) < 0.03
)
_check(
(meets_ellipse or meets_shore) and absf(start.y - pool.global_position.y) < 0.03,
"River outlet should meet the pool ellipse or its terrain shoreline at the same surface height"
)
var material := pool_mesh.surface_get_material(0) as ShaderMaterial
_check(
is_equal_approx(float(material.get_shader_parameter("pool_outlet_z")), start.z),
"Pool transparency should end where the river takes over to avoid coplanar overlap"
)
func _wait_for_navigation_map(navigation_map: RID) -> void: func _wait_for_navigation_map(navigation_map: RID) -> void:
for attempt in 30: for attempt in 30:
if ( if (
+20 -5
View File
@@ -26,17 +26,28 @@ func _run() -> void:
var trust_reaction_root: Node3D = visual.get_node("TrustReactionRoot") var trust_reaction_root: Node3D = visual.get_node("TrustReactionRoot")
_check(not trust_reaction_root.visible, "A newly spawned visual should not replay reactions") _check(not trust_reaction_root.visible, "A newly spawned visual should not replay reactions")
_check( _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( _check(
( (
body.mesh is CylinderMesh body.mesh is ArrayMesh
and visual.has_node("Head") and visual.has_node("Head")
and visual.has_node("ArmLeft") 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( _check(
( (
@@ -184,6 +195,10 @@ func _run() -> void:
_check(view.reload_npc_visual(npc.id), "Visual should reload") _check(view.reload_npc_visual(npc.id), "Visual should reload")
var reloaded: Node3D = view.active_npc_visuals[npc.id] var reloaded: Node3D = view.active_npc_visuals[npc.id]
reloaded.set_physics_process(false) 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( _check(
reloaded.global_position.is_equal_approx(authoritative_position), reloaded.global_position.is_equal_approx(authoritative_position),
"Reloaded visual should use authoritative position" "Reloaded visual should use authoritative position"
+29
View File
@@ -0,0 +1,29 @@
extends GutTest
const CYCLE := preload("res://world/jajce/day_night_cycle.gd")
func test_sun_is_highest_at_noon_and_lights_the_ground_throughout_day() -> void:
var cycle := CYCLE.new()
var light := DirectionalLight3D.new()
cycle.directional_light = light
cycle._apply_light_rotation(0.5)
var noon_elevation := -light.rotation_degrees.x
assert_gt(noon_elevation, 45.0, "Noon should have an elevated sun, not grazing light")
for time in [0.15, 0.25, 0.4, 0.6, 0.75, 0.85]:
cycle._apply_light_rotation(time)
assert_lt(light.rotation_degrees.x, 0.0, "The daylight source points down toward terrain")
assert_lt(-light.rotation_degrees.x, noon_elevation, "The solar arc peaks at noon")
cycle.free()
light.free()
func test_warm_transitions_overlap_dawn_and_dusk_without_persisting_at_noon() -> void:
var cycle := CYCLE.new()
for time in [0.22, 0.78]:
assert_gt(cycle._day_factor(time), 0.0)
assert_lt(cycle._day_factor(time), 1.0)
assert_gt(cycle._sunrise_sunset_weight(time), 0.8)
assert_eq(cycle._sunrise_sunset_weight(0.5), 0.0)
assert_eq(cycle._sunrise_sunset_weight(0.0), 0.0)
cycle.free()
@@ -0,0 +1 @@
uid://dnjghjrvx52cr
+1
View File
@@ -0,0 +1 @@
+176
View File
@@ -0,0 +1,176 @@
"""Build the original Jajce meadow flowers in Blender 5.1+.
Blender --background --python tools/art/build_meadow_flowers.py
Each clump has one opaque vertex-colored surface and no textures. Coordinates
are authored Y-up and converted for Blender; GLB export restores Godot Y-up.
The .blend source files are editable and excluded from Godot import.
"""
import json
import math
from pathlib import Path
import bpy
from mathutils import Vector
ROOT = Path(__file__).resolve().parents[2]
SOURCE = ROOT / "art/meadow"
OUTPUT = ROOT / "assets/meadow"
SOURCE.mkdir(parents=True, exist_ok=True)
OUTPUT.mkdir(parents=True, exist_ok=True)
def linear(hex_color):
values = [int(hex_color[i:i + 2], 16) / 255 for i in (0, 2, 4)]
return tuple(v / 12.92 if v <= 0.04045 else ((v + 0.055) / 1.055) ** 2.4 for v in values)
def coord(point):
return (point[0], -point[2], point[1])
class FlowerMesh:
def __init__(self):
self.vertices = []
self.faces = []
self.colors = []
def face(self, points, color):
first = len(self.vertices)
self.vertices.extend(coord(point) for point in points)
self.faces.append(tuple(range(first, first + len(points))))
self.colors.extend([(*linear(color), 1.0)] * len(points))
def stem(self, base, top):
bottom = Vector(base)
tip = Vector(top)
middle = bottom.lerp(tip, 0.55) + Vector((0.015, 0.0, -0.018))
for start, end in ((bottom, middle), (middle, tip)):
for side in range(4):
a = side * math.tau / 4
b = (side + 1) * math.tau / 4
edge_a = Vector((math.cos(a), 0, math.sin(a))) * 0.009
edge_b = Vector((math.cos(b), 0, math.sin(b))) * 0.009
self.face((start + edge_a, start + edge_b, end + edge_b * 0.5, end + edge_a * 0.5), "54864b")
def leaf(self, base, angle, length=0.18, width=0.042):
root = Vector(base)
direction = Vector((math.cos(angle), 0.6, math.sin(angle))) * length
side = Vector((-math.sin(angle), 0, math.cos(angle))) * width
middle = root + direction * 0.52
tip = root + direction
# Raised crease gives a leaf silhouette from both ground and game camera.
crease = middle + Vector((0, 0.028, 0))
self.face((root, middle + side, crease), "6fa74f")
self.face((middle + side, tip, crease), "80af58")
self.face((root, crease, middle - side), "4f8548")
self.face((middle - side, crease, tip), "679c4e")
def blossom(self, center, radius, petals, color, highlight, heart="e6b344"):
center = Vector(center)
for petal in range(petals):
angle = petal * math.tau / petals + 0.12
forward = Vector((math.cos(angle), 0, math.sin(angle)))
side = Vector((-math.sin(angle), 0, math.cos(angle)))
root = center + forward * radius * 0.12
middle = center + forward * radius * 0.65 + Vector((0, radius * 0.09, 0))
tip = center + forward * radius + Vector((0, radius * 0.24, 0))
width = radius * (0.30 if petals < 7 else 0.23)
self.face((root, middle + side * width, tip + side * width * 0.42), color)
self.face((root, tip + side * width * 0.42, tip - side * width * 0.42), highlight)
self.face((root, tip - side * width * 0.42, middle - side * width), color)
# An opaque domed pollen heart rather than a texture or billboard.
for side in range(8):
a = side * math.tau / 8
b = (side + 1) * math.tau / 8
self.face((center + Vector((0, radius * 0.24, 0)),
center + Vector((math.cos(a) * radius * 0.24, 0.008, math.sin(a) * radius * 0.24)),
center + Vector((math.cos(b) * radius * 0.24, 0.008, math.sin(b) * radius * 0.24))), heart)
def build(self, name):
mesh = bpy.data.meshes.new(name)
mesh.from_pydata(self.vertices, [], self.faces)
mesh.update()
attr = mesh.color_attributes.new(name="Color", type="FLOAT_COLOR", domain="CORNER")
for index, loop in enumerate(mesh.loops):
attr.data[index].color = self.colors[loop.vertex_index]
mesh.materials.append(bpy.data.materials["MeadowPalette"])
obj = bpy.data.objects.new(name, mesh)
bpy.context.collection.objects.link(obj)
return obj
def clear_scene():
bpy.ops.object.select_all(action="SELECT")
bpy.ops.object.delete(use_global=False)
def create_material():
material = bpy.data.materials.new("MeadowPalette")
material.use_nodes = True
material.use_backface_culling = False
nodes = material.node_tree.nodes
nodes.clear()
color = nodes.new("ShaderNodeVertexColor")
color.layer_name = "Color"
surface = nodes.new("ShaderNodeBsdfPrincipled")
surface.inputs["Roughness"].default_value = 1.0
material.node_tree.links.new(color.outputs["Color"], surface.inputs["Base Color"])
output = nodes.new("ShaderNodeOutputMaterial")
material.node_tree.links.new(surface.outputs["BSDF"], output.inputs["Surface"])
def flower(name, colors, petals, height, radius):
clear_scene()
builder = FlowerMesh()
for index, (x, z, scale) in enumerate(((0, 0, 1.0), (-0.16, 0.1, 0.76), (0.16, -0.1, 0.88))):
top = (x + 0.035 * math.cos(index * 2.2), height * scale, z + 0.04)
builder.stem((x, 0, z), top)
for leaf in range(3):
y = height * scale * (0.17 + leaf * 0.16)
builder.leaf((x, y, z), index * 2.1 + leaf * 2.45, 0.17 * scale, 0.034)
if name == "lavender_spire":
for tier in range(4):
pos = (top[0], top[1] - tier * 0.086, top[2])
builder.blossom(pos, radius * (0.58 + tier * 0.13), 5, *colors, heart="c7b3df")
else:
builder.blossom(top, radius * scale, petals, *colors)
obj = builder.build(name)
bpy.ops.object.select_all(action="DESELECT")
obj.select_set(True)
bpy.context.view_layer.objects.active = obj
bpy.ops.export_scene.gltf(
filepath=str(OUTPUT / (name + ".glb")), export_format="GLB",
use_selection=True, export_yup=True, export_animations=False,
export_vertex_color="ACTIVE", export_all_vertex_colors=False,
export_materials="EXPORT", export_extras=False, export_cameras=False,
)
for screen in bpy.data.screens:
for area in screen.areas:
if area.type == "VIEW_3D":
area.spaces.active.shading.type = "MATERIAL"
bpy.context.preferences.filepaths.save_version = 0
bpy.ops.wm.save_as_mainfile(filepath=str(SOURCE / (name + ".blend")))
obj.data.calc_loop_triangles()
return {
"triangles": len(obj.data.loop_triangles), "surfaces": 1,
"flower_stems": 3, "bytes": (OUTPUT / (name + ".glb")).stat().st_size,
}
def main():
clear_scene()
create_material()
specs = (
("cream_daisy", ("f0e7c4", "fff4d8"), 8, 0.47, 0.125),
("pink_cosmos", ("d780a7", "f1abc4"), 7, 0.64, 0.165),
("yellow_buttercup", ("edc45a", "ffdf83"), 5, 0.36, 0.10),
("lavender_spire", ("9585b8", "bfafd6"), 5, 0.79, 0.080),
)
stats = {spec[0]: flower(*spec) for spec in specs}
(OUTPUT / "mesh_budget.json").write_text(json.dumps(stats, indent=2) + "\n")
print(json.dumps(stats, indent=2))
if __name__ == "__main__":
main()
+267
View File
@@ -0,0 +1,267 @@
"""Build original scalloped tree meshes, editable in Blender and portable to Godot.
Run: Blender --background --python tools/art/build_painterly_trees.py
One crown surface and one joined trunk/branch surface; no textures or alpha cards.
All coordinates use Blender Z-up and glTF exports them as Godot Y-up.
"""
import json
import math
import random
from pathlib import Path
import bpy
from mathutils import Vector
ROOT = Path(__file__).resolve().parents[2]
SOURCE = ROOT / "art/painterly"
OUTPUT = ROOT / "assets/painterly"
SOURCE.mkdir(parents=True, exist_ok=True)
OUTPUT.mkdir(parents=True, exist_ok=True)
(SOURCE / ".gdignore").write_text("")
RNG = random.Random(2741)
bpy.ops.object.select_all(action="SELECT")
bpy.ops.object.delete(use_global=False)
def material(name):
mat = bpy.data.materials.new(name)
mat.use_nodes = True
nodes = mat.node_tree.nodes
shader = nodes.get("Principled BSDF")
shader.inputs["Roughness"].default_value = 0.95
shader.inputs["Specular IOR Level"].default_value = 0.0
color = nodes.new("ShaderNodeVertexColor")
color.layer_name = "Color"
mat.node_tree.links.new(color.outputs["Color"], shader.inputs["Base Color"])
return mat
def mesh_object(name, vertices, faces, colors, mat):
mesh = bpy.data.meshes.new(name)
mesh.from_pydata(vertices, [], faces)
mesh.update()
obj = bpy.data.objects.new(name, mesh)
bpy.context.collection.objects.link(obj)
attr = mesh.color_attributes.new(name="Color", type="FLOAT_COLOR", domain="POINT")
for entry, color in zip(attr.data, colors):
entry.color = (*color, 1.0)
mesh.materials.append(mat)
for polygon in mesh.polygons:
polygon.use_smooth = True
return obj
def source_tint(mat, tint):
"""Set an editing preview tint after portable glTF export."""
nodes = mat.node_tree.nodes
mix = nodes.new("ShaderNodeMixRGB")
mix.blend_type = "MULTIPLY"
mix.inputs[0].default_value = 1.0
mix.inputs[2].default_value = (*tint, 1.0)
mat.node_tree.links.new(nodes.get("Color Attribute").outputs["Color"], mix.inputs[1])
mat.node_tree.links.new(mix.outputs[0], nodes.get("Principled BSDF").inputs["Base Color"])
# Connected, irregular crown with broad painted lobes and small leafy tips.
# Perturbing an envelope keeps the silhouette solid and removes internal overdraw.
bumps = []
for index in range(23):
z = 1 - 2 * (index + 0.5) / 23
angle = index * 2.399963 + 0.2
direction = Vector((math.sqrt(1 - z * z) * math.cos(angle),
math.sqrt(1 - z * z) * math.sin(angle), z))
bumps.append((direction, RNG.uniform(0.12, 0.25)))
def envelope(direction):
ridges = sum(height * math.exp((direction.dot(axis) - 1) * 30)
for axis, height in bumps)
fine = math.sin(direction.x * 22 + direction.z * 9) * math.sin(direction.y * 19) * 0.018
radius = 0.84 + ridges + fine
return Vector((direction.x * radius, direction.y * radius * 0.95,
direction.z * radius * 0.86))
def pigment(point):
broad = math.sin(point.x * 8 + point.z * 2) * math.sin(point.y * 7 - point.z * 5)
light = 0.82 + 0.11 * broad + 0.07 * max(0, point.z)
return (light * (1.0 + 0.035 * broad), light, light * (0.95 - 0.025 * broad))
vertices, faces, colors = [], [], []
segments, rings = 32, 16
vertices.append(envelope(Vector((0, 0, 1))))
for ring in range(1, rings):
phi = math.pi * ring / rings
for segment in range(segments):
theta = math.tau * segment / segments
direction = Vector((math.sin(phi) * math.cos(theta), math.sin(phi) * math.sin(theta), math.cos(phi)))
vertices.append(envelope(direction))
vertices.append(envelope(Vector((0, 0, -1))))
for segment in range(segments):
faces.append((0, 1 + segment, 1 + (segment + 1) % segments))
for ring in range(rings - 2):
for segment in range(segments):
a = 1 + ring * segments + segment
b = 1 + ring * segments + (segment + 1) % segments
faces.append((a, a + segments, b + segments, b))
last = len(vertices) - 1
for segment in range(segments):
faces.append((last, last - segments + (segment + 1) % segments, last - segments + segment))
colors = [pigment(point) for point in vertices]
# Bent solid leaf tips give a scalloped edge at close range. They are tiny opaque
# wedges with both sides, so they do not require cull-disabled or alpha materials.
for axis, _height in bumps:
for leaf in range(2):
tangent = axis.cross(Vector((0, 0, 1)))
if tangent.length < 0.01:
tangent = Vector((1, 0, 0))
tangent.normalize()
sideways = axis.cross(tangent).normalized()
direction = (axis + tangent * (leaf - 0.5) * 0.13).normalized()
base = envelope(direction) * 0.98
length = RNG.uniform(0.11, 0.17)
width = RNG.uniform(0.04, 0.065)
middle = base + direction * length * 0.55
start = len(vertices)
vertices.extend([base, middle + tangent * width, middle + sideways * width * 0.28,
middle - tangent * width, base + direction * length])
faces.extend([tuple(start + j for j in indices) for indices in
((0, 1, 2), (0, 2, 3), (1, 4, 2), (2, 4, 3), (0, 3, 1), (1, 3, 4))])
colors.extend([pigment(point) for point in vertices[-5:]])
canopy = mesh_object("Canopy", vertices, faces, colors, material("CanopyPigment"))
# A crooked trunk, joined forks and roots use one surface. Colors are linear,
# with grey warm bark, moss at the base and subtle broad plane variation.
vertices, faces, colors = [], [], []
def tube(points, radii, sides=9):
start = len(vertices)
for index, raw in enumerate(points):
point = Vector(raw)
tangent = Vector(points[min(index + 1, len(points) - 1)]) - Vector(points[max(0, index - 1)])
tangent.normalize()
side = tangent.cross(Vector((0, 1, 0))).normalized()
other = tangent.cross(side).normalized()
for segment in range(sides):
angle = math.tau * segment / sides
offset = (math.cos(angle) * side + math.sin(angle) * other) * radii[index]
vertices.append(point + offset)
shade = 0.90 + math.sin(angle * 3 + 0.6) * 0.12
moss = max(0, 1 - point.z / 0.55) * (0.5 + 0.5 * math.cos(angle))
colors.append(((0.20 - moss * 0.035) * shade, (0.13 + moss * 0.01) * shade,
(0.068 - moss * 0.005) * shade))
if index:
for segment in range(sides):
a = start + (index - 1) * sides + segment
b = start + (index - 1) * sides + (segment + 1) % sides
faces.append((a, b, b + sides, a + sides))
faces.append(tuple(reversed(range(start, start + sides))))
faces.append(tuple(range(len(vertices) - sides, len(vertices))))
tube([(0, 0, 0), (-0.07, 0.025, 0.22), (0.02, 0.0, 1.0), (-0.10, 0.025, 1.85),
(0.03, 0.02, 2.62), (0.22, -0.04, 3.48)], [0.38, 0.27, 0.23, 0.20, 0.14, 0.025])
tube([(-0.06, 0, 1.70), (-0.36, 0.04, 2.35), (-0.84, 0.03, 2.83), (-1.05, 0.09, 3.23)],
[0.18, 0.13, 0.075, 0.012], 7)
tube([(0.00, 0, 2.10), (0.39, 0.12, 2.48), (0.81, 0.18, 2.94), (1.00, 0.25, 3.29)],
[0.14, 0.10, 0.055, 0.012], 7)
tube([(-0.06, 0.025, 2.34), (-0.22, -0.38, 2.83), (-0.35, -0.62, 3.26)],
[0.10, 0.058, 0.012], 7)
for index in range(5):
angle = index * math.tau / 5 + 0.2
tube([(math.cos(angle) * 0.13, math.sin(angle) * 0.13, 0.30),
(math.cos(angle) * 0.36, math.sin(angle) * 0.36, 0.08),
(math.cos(angle + 0.12) * 0.66, math.sin(angle + 0.12) * 0.66, -0.015)],
[0.12, 0.085, 0.012], 6)
trunk = mesh_object("Trunk", vertices, faces, colors, material("BarkPigment"))
# Export object origins at zero; runtime scales/repositions four shared crowns.
bpy.ops.object.select_all(action="SELECT")
bpy.ops.export_scene.gltf(filepath=str(OUTPUT / "painterly_tree.glb"), export_format="GLB",
use_selection=True, export_yup=True, export_animations=False,
export_vertex_color="ACTIVE", export_all_vertex_colors=False,
export_materials="EXPORT", export_extras=False, export_cameras=False)
# Display a complete tree in the editable source without baking placement into GLB.
source_tint(canopy.data.materials[0], (0.22, 0.42, 0.10))
canopy.location.z = 3.8
canopy.scale = (1.8, 1.55, 1.4)
for location, scale in (((-0.85, 0.05, 3.55), (1.28, 1.22, 1.15)),
((0.87, -0.08, 3.67), (1.27, 1.15, 1.1)),
((0.05, 0, 4.46), (1.20, 1.08, 1.0))):
crown = canopy.copy()
crown.data = canopy.data
bpy.context.collection.objects.link(crown)
crown.location = location
crown.scale = scale
for screen in bpy.data.screens:
for area in screen.areas:
if area.type == "VIEW_3D":
area.spaces.active.shading.type = "MATERIAL"
bpy.context.preferences.filepaths.save_version = 0
bpy.ops.wm.save_as_mainfile(filepath=str(SOURCE / "painterly_tree.blend"))
budget = {}
for obj in (canopy, trunk):
obj.data.calc_loop_triangles()
budget[obj.name] = {"triangles": len(obj.data.loop_triangles), "surfaces": 1,
"vertices": len(obj.data.vertices)}
budget["runtime_tree"] = {"triangles": budget["Canopy"]["triangles"] * 4 + budget["Trunk"]["triangles"],
"surfaces": 5, "shared_meshes": 2}
budget["glb_bytes"] = (OUTPUT / "painterly_tree.glb").stat().st_size
(OUTPUT / "tree_mesh_budget.json").write_text(json.dumps(budget, indent=2) + "\n")
print("PAINTERLY TREE BUDGET", json.dumps(budget))
# Narrow evergreen boughs, scalloped skirts and an uneven leader. One opaque
# surface is joined across eight whorls, preserving a restrained draw budget.
bpy.ops.object.select_all(action="SELECT")
bpy.ops.object.delete(use_global=False)
vertices, faces, colors = [], [], []
segments = 24
for tier in range(8):
height = 0.64 + tier * 0.27
radius = 0.62 * (1 - tier / 8.8)
start = len(vertices)
angle_offset = tier * 0.61
lean_x = math.sin(tier * 0.8) * 0.035
lean_y = math.cos(tier * 1.1) * 0.025
for ring, (radial, lift) in enumerate(((0.78, 0.04), (1.0, 0.08), (0.63, 0.25), (0.12, 0.57))):
for segment in range(segments):
angle = math.tau * segment / segments + angle_offset
tip = (1 + math.cos(angle * 8 + tier)) * 0.5
organic = math.sin(angle * 3 + tier) * 0.08 + math.sin(angle * 5 - tier * 2) * 0.035
r = radius * radial * (0.84 + tip * 0.16 + organic)
z = height + lift - tip * (0.085 if ring < 2 else 0.022)
vertices.append((lean_x + math.cos(angle) * r, lean_y + math.sin(angle) * r, z))
pigment = 0.72 + tier * 0.015 + ring * 0.05 + tip * 0.045
colors.append((pigment * 0.94, pigment, pigment * 0.93))
if ring:
a = start + (ring - 1) * segments + segment
b = start + (ring - 1) * segments + (segment + 1) % segments
faces.append((a, b, b + segments, a + segments))
faces.append(tuple(reversed(range(start, start + segments))))
faces.append(tuple(range(len(vertices) - segments, len(vertices))))
conifer = mesh_object("ConiferCanopy", vertices, faces, colors, material("EvergreenPigment"))
vertices, faces, colors = [], [], []
tube([(0, 0, 0), (0.015, -0.01, 0.48), (-0.02, 0, 1.5), (0, 0, 2.58), (0.01, 0, 2.84)],
[0.10, 0.07, 0.045, 0.02, 0.004], 8)
conifer_trunk = mesh_object("ConiferTrunk", vertices, faces, colors, material("EvergreenBark"))
bpy.ops.object.select_all(action="SELECT")
bpy.ops.export_scene.gltf(filepath=str(OUTPUT / "painterly_conifer.glb"), export_format="GLB",
use_selection=True, export_yup=True, export_animations=False,
export_vertex_color="ACTIVE", export_all_vertex_colors=False,
export_materials="EXPORT", export_extras=False, export_cameras=False)
source_tint(conifer.data.materials[0], (0.10, 0.28, 0.17))
bpy.ops.wm.save_as_mainfile(filepath=str(SOURCE / "painterly_conifer.blend"))
for obj in (conifer, conifer_trunk):
obj.data.calc_loop_triangles()
budget["conifer"] = {"triangles": sum(len(o.data.loop_triangles) for o in (conifer, conifer_trunk)),
"surfaces": 2, "height_m": 3.10,
"glb_bytes": (OUTPUT / "painterly_conifer.glb").stat().st_size}
(OUTPUT / "tree_mesh_budget.json").write_text(json.dumps(budget, indent=2) + "\n")
print("CONIFER BUDGET", json.dumps(budget["conifer"]))
+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))
+106
View File
@@ -0,0 +1,106 @@
extends SceneTree
## Native renderer review of a scripted walk through the actual loaded meadow.
## The capture moves presentation only and verifies the simulation checksum.
const OUTPUT := "res://docs/baselines/foliage_"
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
root.size = Vector2i(1600, 900)
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 checksum: String = manager.get_state_checksum()
var world := main.get_node("JajceWorld") as JajceWorld
world.apply_presentation_quality(JajceWorld.PresentationQuality.HIGH)
var controller := world.grass_controller
controller.set_process(false)
var cycle := world.get_node("DayNightCycle")
cycle.set_process(false)
cycle.call("_apply_light_rotation", 0.4)
cycle.call("_apply_environment", 0.4)
for layer in main.get_children():
if layer is CanvasLayer:
layer.hide()
for actor in get_nodes_in_group("grass_interactors"):
actor.set_physics_process(false)
actor.set_process(false)
var player := main.get_node("Player") as CharacterBody3D
player.get_node("Visual/Sword").hide()
main.get_node("CameraRig").set_physics_process(false)
var camera := root.get_camera_3d()
camera.reparent(main)
camera.global_position = Vector3(16, 7.5, 23)
camera.look_at(Vector3(11, 0.6, 10.5))
camera.fov = 48
player.position = Vector3(5, 0, 10.85)
player.get_node("Visual").rotation.y = -PI / 2.0
for _frame in 90:
await process_frame
# Freeze wind and particles so the four review frames isolate contact deformation.
Engine.time_scale = 0.0
var demo := main.get_node("DemoController")
if demo.get("debug_overlay_visible"):
demo.call("toggle_debug_overlay")
for layer in main.get_children():
if layer is CanvasLayer:
layer.hide()
controller.call("reset_interaction")
await _save("before.png")
var update_times: Array[float] = []
for step in 61:
player.position.x = 5.0 + step * 0.18
player.position.y = world.terrain.data.get_height(player.position) + 0.02
var start := Time.get_ticks_usec()
controller.call("_refresh_interactors", 0.08)
update_times.append(float(Time.get_ticks_usec() - start) / 1000.0)
for _frame in 5:
await process_frame
if step == 37:
await _save("contact.png")
await _save("trail.png")
var active_cells := (controller.get("_trail_map").get("_cells") as Dictionary).size()
var draws := Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME)
for _step in 110:
controller.call("_refresh_interactors", 0.08)
for _frame in 5:
await process_frame
await _save("recovered.png")
update_times.sort()
var report := {
"renderer": RenderingServer.get_current_rendering_driver_name(),
"resolution": "1600x900",
"profile": "High",
"workload":
"Actual Jajce meadow; 8 nearest actors, player presentation follows an 11 m sweep",
"controller_update_p50_ms": update_times[30],
"controller_update_p95_ms": update_times[57],
"active_trail_texels": active_cells,
"trail_texture_bytes": 128 * 128 * 16,
"draw_calls": draws,
"simulation_checksum_unchanged": manager.get_state_checksum() == checksum,
"note":
"CPU timing includes scanning, field painting and material uploads; not isolated GPU frame time."
}
var file := FileAccess.open(OUTPUT + "render_metrics.json", FileAccess.WRITE)
file.store_string(JSON.stringify(report, "\t") + "\n")
print(JSON.stringify(report))
assert(manager.get_state_checksum() == checksum)
main.free()
Engine.time_scale = 1.0
await process_frame
quit(0)
func _save(filename: String) -> void:
await RenderingServer.frame_post_draw
var capture := root.get_texture().get_image()
assert(not capture.is_empty())
assert(capture.save_png(OUTPUT + filename) == OK)
+1
View File
@@ -0,0 +1 @@
uid://bqd5coilksebi
+127
View File
@@ -0,0 +1,127 @@
extends SceneTree
## Main-scene review at fixed simulation time; never writes a save.
const OUTPUT := "res://docs/baselines/"
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
root.size = Vector2i(1600, 900)
var label := "after"
for argument in OS.get_cmdline_user_args():
if argument.begins_with("--label="):
label = argument.trim_prefix("--label=").validate_filename()
var main := load("res://main.tscn").instantiate() as Node3D
main.get_node("SimulationManager").set("debug_logs", false)
root.add_child(main)
var rig := main.get_node("CameraRig")
rig.set_process_unhandled_input(false)
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
await process_frame
main.get_node("SimulationManager").set_process(false)
var cycle := main.get_node("JajceWorld/DayNightCycle")
cycle.set_process(false)
cycle.call("_apply_light_rotation", 0.4)
cycle.call("_apply_environment", 0.4)
var demo := main.get_node("DemoController")
if demo.get("debug_overlay_visible"):
demo.call("toggle_debug_overlay")
for child in main.get_children():
if child is CanvasLayer:
child.hide()
for _frame in 45:
await process_frame
main.get_node("Player").set_physics_process(false)
rig.set_physics_process(false)
rig.call("apply_presentation_preset", main.get_node("Player").global_position, 0.0, 0.5)
await _save(label + "_gameplay")
var camera := root.get_camera_3d()
camera.reparent(main)
camera.global_position = Vector3(38, 24, 44)
camera.look_at(Vector3(-4, 3, 0))
camera.fov = 53.0
await _save(label + "_valley")
camera.global_position = Vector3(38, 24, 44)
camera.look_at(Vector3(-4, 12, 0))
camera.fov = 58.0
await _save(label + "_vista")
camera.global_position = Vector3(5, 4.8, 24)
camera.look_at(Vector3(-7, 2.8, 9))
camera.fov = 55.0
await _save(label + "_meadow")
cycle.call("_apply_light_rotation", 0.92)
cycle.call("_apply_environment", 0.92)
await _save(label + "_night")
cycle.call("_apply_light_rotation", 0.4)
cycle.call("_apply_environment", 0.4)
camera.global_position = Vector3(38, 24, 44)
camera.look_at(Vector3(-4, 3, 0))
camera.fov = 53.0
await _sample_profiles(main, label)
quit(0)
func _sample_profiles(main: Node3D, label: String) -> void:
var world := main.get_node("JajceWorld") as JajceWorld
var samples: Array[Dictionary] = []
for quality in [0, 1, 2]:
world.apply_presentation_quality(quality)
for _frame in 45:
await process_frame
var frame_times: Array[float] = []
for _frame in 120:
var start := Time.get_ticks_usec()
await process_frame
frame_times.append(float(Time.get_ticks_usec() - start) / 1000.0)
frame_times.sort()
var sample := {
"profile": world.get_active_presentation_quality_name(),
"render_scale": root.scaling_3d_scale,
"msaa_enum": root.msaa_3d,
"wall_frame_p50_ms": frame_times[60],
"wall_frame_p95_ms": frame_times[114],
"draw_calls": Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME),
"primitives": Performance.get_monitor(Performance.RENDER_TOTAL_PRIMITIVES_IN_FRAME),
"active_npcs": main.get_node("ActiveNPCs").get_child_count(),
}
if world.has_node("PainterlyMeadow"):
sample["meadow"] = world.get_node("PainterlyMeadow").get_presentation_stats()
samples.append(sample)
var file := FileAccess.open(OUTPUT + "painterly_" + label + "_metrics.json", FileAccess.WRITE)
assert(file != null)
(
file
. store_string(
(
(
JSON
. stringify(
{
"renderer": RenderingServer.get_current_rendering_driver_name(),
"device": RenderingServer.get_video_adapter_name(),
"resolution": "1600x900",
"workload":
"Paused main scene, static valley camera; wall time includes vsync",
"samples": samples,
},
"\t"
)
)
+ "\n"
)
)
)
func _save(label: String) -> void:
for _frame in 75:
await process_frame
await RenderingServer.frame_post_draw
var capture := root.get_texture().get_image()
assert(not capture.is_empty())
var path := OUTPUT + "painterly_" + label + ".png"
assert(capture.save_png(path) == OK)
print("[TOOL] Saved " + path)
@@ -0,0 +1 @@
uid://d136scmaukb0d
+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"] [ext_resource type="Shader" path="res://world/presentation/materials/storybook_surface.gdshader" id="story_surface"]
albedo_color = Color(0.61, 0.53, 0.42, 1)
roughness = 0.94 [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"] [sub_resource type="BoxMesh" id="Mesh_deck"]
material = SubResource("Material_deck") material = SubResource("Material_deck")
size = Vector3(15.2, 0.32, 3.15) size = Vector3(15.2, 0.32, 3.15)
[sub_resource type="StandardMaterial3D" id="Material_stone"] [sub_resource type="ShaderMaterial" id="Material_stone"]
albedo_color = Color(0.48, 0.47, 0.41, 1) shader = ExtResource("story_surface")
roughness = 0.98 shader_parameter/albedo = Color(0.48, 0.47, 0.41, 1)
[sub_resource type="BoxMesh" id="Mesh_abutment"] [sub_resource type="BoxMesh" id="Mesh_abutment"]
material = SubResource("Material_stone") material = SubResource("Material_stone")
@@ -20,9 +22,9 @@ size = Vector3(2.25, 1.45, 4.2)
material = SubResource("Material_stone") material = SubResource("Material_stone")
size = Vector3(1.05, 1.65, 3.7) size = Vector3(1.05, 1.65, 3.7)
[sub_resource type="StandardMaterial3D" id="Material_rail"] [sub_resource type="ShaderMaterial" id="Material_rail"]
albedo_color = Color(0.2, 0.115, 0.055, 1) shader = ExtResource("story_surface")
roughness = 0.92 shader_parameter/albedo = Color(0.2, 0.115, 0.055, 1)
[sub_resource type="BoxMesh" id="Mesh_rail"] [sub_resource type="BoxMesh" id="Mesh_rail"]
material = SubResource("Material_rail") material = SubResource("Material_rail")
+98
View File
@@ -0,0 +1,98 @@
class_name FoliageTrailMap
extends RefCounted
## Disposable, camera-local footprint field. No per-blade nodes or physics.
## RG = weighted bend direction, B = pressure, A = weighted actor foot height.
const RESOLUTION := 128
const CELL_SIZE := 0.5
const WORLD_SIZE := RESOLUTION * CELL_SIZE
const TRAIL_RADIUS := 0.95
const RECOVERY_SECONDS := 8.0
const MAX_SEGMENT_LENGTH := 3.0
var texture: ImageTexture
var world_rect := Vector4.ZERO
var _image: Image
var _origin := Vector2i.ZERO
var _cells: Dictionary[Vector2i, Color] = {}
var _previous_positions: Dictionary[int, Vector3] = {}
func _init() -> void:
_image = Image.create(RESOLUTION, RESOLUTION, false, Image.FORMAT_RGBAF)
_image.fill(Color(0, 0, 0, 0))
texture = ImageTexture.create_from_image(_image)
func clear() -> void:
_cells.clear()
_previous_positions.clear()
_image.fill(Color(0, 0, 0, 0))
texture.update(_image)
func advance(delta: float, center: Vector3, positions: Dictionary[int, Vector3]) -> void:
var next_origin := _world_cell(Vector2(center.x, center.z)) - Vector2i.ONE * (RESOLUTION / 2)
var dirty := not _cells.is_empty() or next_origin != _origin
_origin = next_origin
world_rect = Vector4(_origin.x * CELL_SIZE, _origin.y * CELL_SIZE, WORLD_SIZE, WORLD_SIZE)
_decay_and_clip(maxf(delta, 0.0))
for actor_id in positions:
var end := positions[actor_id]
var start: Vector3 = _previous_positions.get(actor_id, end)
# A load, teleport, spawn, or skipped actor must never draw a connecting scar.
if start.distance_to(end) > MAX_SEGMENT_LENGTH or delta > 0.5:
start = end
_stamp_segment(start, end)
_previous_positions = positions.duplicate()
if not dirty and _cells.is_empty():
return
_image.fill(Color(0, 0, 0, 0))
for cell in _cells:
var pixel := cell - _origin
_image.set_pixelv(pixel, _cells[cell])
texture.update(_image)
func _decay_and_clip(delta: float) -> void:
for cell in _cells.keys():
var value := _cells[cell]
var pressure := maxf(0.0, value.b - delta / RECOVERY_SECONDS)
if pressure <= 0.001 or not _contains(cell):
_cells.erase(cell)
else:
_cells[cell] = value * (pressure / value.b)
func _stamp_segment(start: Vector3, end: Vector3) -> void:
var a := Vector2(start.x, start.z)
var b := Vector2(end.x, end.z)
var line := b - a
var length_squared := line.length_squared()
var radius := Vector2.ONE * TRAIL_RADIUS
var first := _world_cell(a.min(b) - radius).max(_origin)
var last := _world_cell(a.max(b) + radius).min(_origin + Vector2i.ONE * (RESOLUTION - 1))
for y in range(first.y, last.y + 1):
for x in range(first.x, last.x + 1):
var cell := Vector2i(x, y)
var point := (Vector2(cell) + Vector2.ONE * 0.5) * CELL_SIZE
var along := clampf((point - a).dot(line) / maxf(length_squared, 0.0001), 0.0, 1.0)
var offset := point - a - line * along
var pressure := 1.0 - smoothstep(0.16, TRAIL_RADIUS, offset.length())
var existing: Color = _cells.get(cell, Color(0, 0, 0, 0))
if pressure <= 0.001 or pressure < existing.b:
continue
# A swept capsule parts blades to both sides of the travelled line.
var away := offset.normalized()
var height := lerpf(start.y, end.y, along)
_cells[cell] = Color(away.x, away.y, 1.0, height) * pressure
func _world_cell(position: Vector2) -> Vector2i:
return Vector2i(floori(position.x / CELL_SIZE), floori(position.y / CELL_SIZE))
func _contains(cell: Vector2i) -> bool:
var pixel := cell - _origin
return pixel.x >= 0 and pixel.y >= 0 and pixel.x < RESOLUTION and pixel.y < RESOLUTION
+1
View File
@@ -0,0 +1 @@
uid://6llj4y8dy0jm

Some files were not shown because too many files have changed in this diff Show More