Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 70ee6feddf | |||
| ec16e2f2ca | |||
| 7899d36f03 | |||
| da3bed67a0 |
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
After Width: | Height: | Size: 1.4 MiB |
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
# Woodland storybook art
|
||||
|
||||
Original Blender models for The Steward's warm, painted animation direction.
|
||||
The runtime cast is a fox traveler, rabbit gardener, bespectacled badger and
|
||||
otter in oversized coats, scarves, boots and satchels. These are original
|
||||
characters, not models of existing film characters.
|
||||
|
||||
`art/storybook/` contains the editable Blender 5.1 sources. Its `.gdignore`
|
||||
keeps Blender out of Godot's runtime import dependency chain. This directory
|
||||
contains portable GLBs and the extracted grass mesh consumed by Terrain3D.
|
||||
|
||||
## Rebuild
|
||||
|
||||
From the project root, with Blender and Godot 4.7 installed:
|
||||
|
||||
```sh
|
||||
"$BLENDER_BIN" --background --python tools/art/build_storybook_assets.py
|
||||
"$GODOT_BIN" --headless --editor --path "$PWD" --import
|
||||
"$GODOT_BIN" --headless --path "$PWD" --script tools/import_storybook_grass.gd
|
||||
```
|
||||
|
||||
On macOS the binaries used for this pass were
|
||||
`/Applications/Blender.app/Contents/MacOS/Blender` and
|
||||
`/Applications/Godot.app/Contents/MacOS/Godot`. Headless CI should use the
|
||||
isolated profile configured by `tools/quality.sh`.
|
||||
|
||||
## Mesh and material contract
|
||||
|
||||
- The seven-blade meadow tuft has **35 triangles**, one surface, rooted Y=0,
|
||||
and UV.y=0 at each root / 1 at each tip. Wind and actor bending run on the
|
||||
GPU. The material is opaque and double-sided, with no alpha cards, texture
|
||||
lookups, per-blade nodes, shadow casting, or outline pass.
|
||||
- Terrain3D owns the existing nine camera-local emitter cells: 10,404 tuft
|
||||
slots on High, 5,184 on Balanced, hidden and stopped on Low. Terrain masks
|
||||
and distance fading further reduce visible grass. Four scene-derived path
|
||||
clearings collapse blades around the walking strips. Changing density does
|
||||
not alter the terrain, navigation, resource state or collision.
|
||||
- Each animal has eight mesh surfaces, **7,884–10,050 triangles** total, and
|
||||
vertex colors. `mesh_budget.json` is emitted by the Blender generator.
|
||||
Vertex alpha is a clothing tint mask; the cel shader never writes ALPHA.
|
||||
- `AnimalAppearance` derives NPC species from stable ID modulo four; the
|
||||
player is a fox. It shares imported meshes, creates an isolated material
|
||||
per actor and tints clothing from the existing profession definition.
|
||||
`Body`, `Head`, `Hair`, four limbs and `Tail` preserve the visual controllers'
|
||||
animation slots. Shoulder/hip pivots support the existing velocity-driven
|
||||
gait. Ears, face details, boots and scarves are joined into those surfaces.
|
||||
- Inventory sacks, carried logs, profession tools, task glyphs, trust reactions,
|
||||
death and player sword cues remain controlled by their existing state paths.
|
||||
Animal species here is appearance only: no animal simulation classification,
|
||||
RNG calls, saved fields, migration or gameplay mechanics are introduced.
|
||||
|
||||
## Review captures
|
||||
|
||||
```sh
|
||||
"$GODOT_BIN" --path "$PWD" --script tools/capture_storybook.gd -- --cast
|
||||
"$GODOT_BIN" --path "$PWD" --script tools/capture_storybook.gd
|
||||
```
|
||||
|
||||
These require a real renderer. The first writes `storybook_cast.png`; the
|
||||
second captures the running main scene at a controlled daytime presentation,
|
||||
then writes gameplay/valley PNGs and a small profile sample to `docs/baselines/`.
|
||||
It pauses simulation only in the capture process and does not save anything.
|
||||
The gameplay camera defaults remain unchanged. The matched environment camera
|
||||
can also be captured with `tools/capture_jajce_lookdev.gd -- --hide-debug-labels
|
||||
--output=res://docs/baselines/storybook_after.png`.
|
||||
|
||||
Profile samples are local wall-frame latency at a static camera with six NPC
|
||||
visuals, including vsync. They are not isolated GPU timings or weak-PC proof.
|
||||
|
||||
The full macOS quality gate passed on Godot 4.7, including all scenarios,
|
||||
Compatibility profile checks and 180 GUT tests / 2,258 assertions. Native
|
||||
Metal captures were visually reviewed. The character shader also rendered on
|
||||
Compatibility, whose color/tonemapping differs substantially from Metal; the
|
||||
approved color reference is `storybook_cast.png` (Metal).
|
||||
|
||||
Native Metal shutdown reports one `ParticlesShaderRD` / material Shader RID
|
||||
retained at exit. The identical diagnostic was reproduced by importing and
|
||||
running untouched commit `6aae41d` in a separate temporary directory. The
|
||||
existing headless gate allowlist was not changed. Import/startup diagnostics
|
||||
and the baseline comparison are in ignored `logs/quality/storybook-*.log`.
|
||||
|
||||
Shader interfaces follow the official [Godot 4.7 spatial shader contract](https://docs.godotengine.org/en/4.7/tutorials/shaders/shader_reference/spatial_shader.html).
|
||||
The exporter uses Blender's [glTF export API](https://docs.blender.org/api/main/bpy.ops.export_scene.html).
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -38,19 +38,81 @@ spawn is a next integration.
|
||||
`JajceWorld` owns reversible High, Balanced, and Low presentation profiles with
|
||||
instance-local mutable environment/grass resources and viewport-scale ownership.
|
||||
|
||||
| Profile | 3D scale | Shadow distance | Volumetric/glow | Grass |
|
||||
| Profile | 3D scale / MSAA | Shadow distance | Volumetric/glow | Grass |
|
||||
| --- | ---: | ---: | --- | --- |
|
||||
| High | authored | authored (currently 180 m) | authored when renderer supports it | authored density/interactors |
|
||||
| Balanced (default) | 0.85 cap | 120 m cap | authored ordinary lookdev; unsupported volumetric disabled | reduced spacing/interactors/update rate |
|
||||
| Low | 0.70 cap | 80 m, orthogonal | volumetric/glow/adjustment disabled; ordinary fog retained | hidden/stopped, emitters off, shader interactors zero |
|
||||
| High | authored / authored 4× | authored (currently 180 m) | authored when renderer supports it | authored density/interactors |
|
||||
| Balanced (default) | 0.85 cap / 2× cap | 120 m cap | authored ordinary lookdev; unsupported volumetric disabled | reduced spacing/interactors/update rate |
|
||||
| Low | 0.70 cap / off | 80 m, orthogonal | volumetric/glow/adjustment disabled; ordinary fog retained | hidden/stopped, emitters off, shader interactors zero |
|
||||
|
||||
UI remains at native resolution. High/Low switches restore captured authored
|
||||
values; viewport scale restoration is guarded by the current quality owner so a
|
||||
second world cannot clobber the active owner. Compatibility/mobile feature
|
||||
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
|
||||
|
||||
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;
|
||||
they no longer rebuild all candidate data every rendered frame.
|
||||
- `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),
|
||||
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.
|
||||
|
||||
|
After Width: | Height: | Size: 1006 KiB |
|
After Width: | Height: | Size: 1008 KiB |
|
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"
|
||||
}
|
||||
|
After Width: | Height: | Size: 1000 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
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"
|
||||
}
|
||||
|
After Width: | Height: | Size: 709 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 815 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
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"
|
||||
}
|
||||
|
After Width: | Height: | Size: 469 KiB |
|
After Width: | Height: | Size: 809 KiB |
|
After Width: | Height: | Size: 798 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
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"
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.1 MiB |
@@ -1,72 +1,7 @@
|
||||
[gd_scene load_steps=17 format=3]
|
||||
[gd_scene load_steps=6 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://player/player_visual.gd" id="1_visual"]
|
||||
|
||||
[sub_resource type="StandardMaterial3D" id="Material_tunic"]
|
||||
albedo_color = Color(0.18, 0.46, 0.48, 1)
|
||||
roughness = 0.84
|
||||
|
||||
[sub_resource type="CylinderMesh" id="Mesh_body"]
|
||||
material = SubResource("Material_tunic")
|
||||
top_radius = 0.28
|
||||
bottom_radius = 0.42
|
||||
height = 0.9
|
||||
|
||||
[sub_resource type="StandardMaterial3D" id="Material_skin"]
|
||||
albedo_color = Color(0.84, 0.61, 0.39, 1)
|
||||
roughness = 0.88
|
||||
|
||||
[sub_resource type="SphereMesh" id="Mesh_head"]
|
||||
material = SubResource("Material_skin")
|
||||
radius = 0.29
|
||||
height = 0.56
|
||||
|
||||
[sub_resource type="StandardMaterial3D" id="Material_hair"]
|
||||
albedo_color = Color(0.2, 0.105, 0.05, 1)
|
||||
roughness = 0.92
|
||||
|
||||
[sub_resource type="CylinderMesh" id="Mesh_hair"]
|
||||
material = SubResource("Material_hair")
|
||||
top_radius = 0.24
|
||||
bottom_radius = 0.29
|
||||
height = 0.18
|
||||
|
||||
[sub_resource type="CylinderMesh" id="Mesh_arm"]
|
||||
material = SubResource("Material_tunic")
|
||||
top_radius = 0.095
|
||||
bottom_radius = 0.11
|
||||
height = 0.58
|
||||
|
||||
[sub_resource type="SphereMesh" id="Mesh_hand"]
|
||||
material = SubResource("Material_skin")
|
||||
radius = 0.12
|
||||
height = 0.22
|
||||
|
||||
[sub_resource type="StandardMaterial3D" id="Material_pants"]
|
||||
albedo_color = Color(0.15, 0.21, 0.24, 1)
|
||||
roughness = 0.9
|
||||
|
||||
[sub_resource type="CapsuleMesh" id="Mesh_leg"]
|
||||
material = SubResource("Material_pants")
|
||||
radius = 0.115
|
||||
height = 0.62
|
||||
|
||||
[sub_resource type="StandardMaterial3D" id="Material_boot"]
|
||||
albedo_color = Color(0.13, 0.075, 0.04, 1)
|
||||
roughness = 0.95
|
||||
|
||||
[sub_resource type="BoxMesh" id="Mesh_foot"]
|
||||
material = SubResource("Material_boot")
|
||||
size = Vector3(0.23, 0.15, 0.36)
|
||||
|
||||
[sub_resource type="StandardMaterial3D" id="Material_accent"]
|
||||
albedo_color = Color(0.86, 0.4, 0.18, 1)
|
||||
roughness = 0.82
|
||||
|
||||
[sub_resource type="BoxMesh" id="Mesh_scarf"]
|
||||
material = SubResource("Material_accent")
|
||||
size = Vector3(0.48, 0.1, 0.38)
|
||||
|
||||
[sub_resource type="StandardMaterial3D" id="Material_sword"]
|
||||
albedo_color = Color(0.72, 0.74, 0.78, 1)
|
||||
roughness = 0.32
|
||||
@@ -85,67 +20,29 @@ top_radius = 0.045
|
||||
bottom_radius = 0.045
|
||||
height = 0.16
|
||||
|
||||
[sub_resource type="SphereMesh" id="Mesh_satchel"]
|
||||
material = SubResource("Material_accent")
|
||||
radius = 0.22
|
||||
height = 0.34
|
||||
|
||||
[node name="PlayerVisual" type="Node3D"]
|
||||
script = ExtResource("1_visual")
|
||||
|
||||
[node name="Body" type="MeshInstance3D" parent="."]
|
||||
position = Vector3(0, 1.05, 0)
|
||||
mesh = SubResource("Mesh_body")
|
||||
|
||||
[node name="Scarf" type="MeshInstance3D" parent="."]
|
||||
position = Vector3(0, 1.42, 0.02)
|
||||
mesh = SubResource("Mesh_scarf")
|
||||
|
||||
[node name="Head" type="MeshInstance3D" parent="."]
|
||||
position = Vector3(0, 1.68, 0)
|
||||
mesh = SubResource("Mesh_head")
|
||||
|
||||
[node name="Hair" type="MeshInstance3D" parent="."]
|
||||
position = Vector3(0, 1.9, -0.015)
|
||||
mesh = SubResource("Mesh_hair")
|
||||
|
||||
[node name="ArmLeft" type="MeshInstance3D" parent="."]
|
||||
position = Vector3(-0.39, 1.08, 0)
|
||||
rotation = Vector3(0, 0, -0.139626)
|
||||
mesh = SubResource("Mesh_arm")
|
||||
|
||||
[node name="Hand" type="MeshInstance3D" parent="ArmLeft"]
|
||||
position = Vector3(0, -0.35, 0)
|
||||
mesh = SubResource("Mesh_hand")
|
||||
position = Vector3(-0.39, 1.3, 0)
|
||||
|
||||
[node name="ArmRight" type="MeshInstance3D" parent="."]
|
||||
position = Vector3(0.39, 1.08, 0)
|
||||
rotation = Vector3(0, 0, 0.139626)
|
||||
mesh = SubResource("Mesh_arm")
|
||||
|
||||
[node name="Hand" type="MeshInstance3D" parent="ArmRight"]
|
||||
position = Vector3(0, -0.35, 0)
|
||||
mesh = SubResource("Mesh_hand")
|
||||
position = Vector3(0.39, 1.3, 0)
|
||||
|
||||
[node name="LegLeft" type="MeshInstance3D" parent="."]
|
||||
position = Vector3(-0.18, 0.43, 0)
|
||||
mesh = SubResource("Mesh_leg")
|
||||
|
||||
[node name="Foot" type="MeshInstance3D" parent="LegLeft"]
|
||||
position = Vector3(0, -0.28, 0.09)
|
||||
mesh = SubResource("Mesh_foot")
|
||||
position = Vector3(-0.18, 0.61, 0)
|
||||
|
||||
[node name="LegRight" type="MeshInstance3D" parent="."]
|
||||
position = Vector3(0.18, 0.43, 0)
|
||||
mesh = SubResource("Mesh_leg")
|
||||
|
||||
[node name="Foot" type="MeshInstance3D" parent="LegRight"]
|
||||
position = Vector3(0, -0.28, 0.09)
|
||||
mesh = SubResource("Mesh_foot")
|
||||
|
||||
[node name="Satchel" type="MeshInstance3D" parent="."]
|
||||
position = Vector3(-0.43, 0.87, -0.04)
|
||||
mesh = SubResource("Mesh_satchel")
|
||||
position = Vector3(0.18, 0.61, 0)
|
||||
|
||||
[node name="Sword" type="Node3D" parent="."]
|
||||
position = Vector3(0.52, 1.22, 0.12)
|
||||
@@ -158,3 +55,6 @@ mesh = SubResource("Mesh_blade")
|
||||
[node name="Grip" type="MeshInstance3D" parent="Sword"]
|
||||
position = Vector3(0, -0.05, 0)
|
||||
mesh = SubResource("Mesh_grip")
|
||||
|
||||
[node name="Tail" type="MeshInstance3D" parent="."]
|
||||
position = Vector3(0, 0.8, -0.22)
|
||||
|
||||
@@ -4,9 +4,9 @@ signal arrived_at_target(sim_id: int)
|
||||
signal navigation_failed(sim_id: int)
|
||||
signal position_changed(sim_id: int, active_position: Vector3)
|
||||
|
||||
const TRUST_REACTION_BASE_POSITION := Vector3(0.0, 2.72, 0.0)
|
||||
const TRUST_REACTION_BASE_POSITION := Vector3(0.0, 3.25, 0.0)
|
||||
const TRUST_REACTION_START_SCALE := Vector3(0.24, 0.24, 0.24)
|
||||
const CONCERN_BASE_POSITION := Vector3(-0.4, 2.72, 0.0)
|
||||
const CONCERN_BASE_POSITION := Vector3(-0.4, 3.25, 0.0)
|
||||
|
||||
@export var debug_logs := false
|
||||
|
||||
@@ -76,7 +76,7 @@ func _process(delta: float) -> void:
|
||||
return
|
||||
glyph_phase = fmod(glyph_phase + delta * 2.2, TAU)
|
||||
if task_glyph_root.visible:
|
||||
task_glyph_root.position.y = 2.35 + sin(glyph_phase) * 0.045
|
||||
task_glyph_root.position.y = 2.9 + sin(glyph_phase) * 0.045
|
||||
task_glyph_root.rotation.y = glyph_phase * 0.35
|
||||
if opportunity_concern_root.visible:
|
||||
concern_phase = fmod(concern_phase + delta * 1.65, TAU)
|
||||
@@ -123,16 +123,7 @@ func setup_from_sim(npc: SimNPC) -> void:
|
||||
|
||||
|
||||
func _apply_personal_details() -> void:
|
||||
var hair_colors := [
|
||||
Color(0.16, 0.09, 0.055, 1.0),
|
||||
Color(0.29, 0.16, 0.075, 1.0),
|
||||
Color(0.09, 0.065, 0.05, 1.0),
|
||||
Color(0.42, 0.27, 0.12, 1.0),
|
||||
]
|
||||
var hair_material := StandardMaterial3D.new()
|
||||
hair_material.albedo_color = hair_colors[maxi(sim_id, 0) % hair_colors.size()]
|
||||
hair_material.roughness = 0.9
|
||||
hair_mesh.material_override = hair_material
|
||||
AnimalAppearance.apply_to(self, sim_id, true)
|
||||
|
||||
|
||||
func _apply_profession_presentation() -> void:
|
||||
@@ -142,12 +133,7 @@ func _apply_profession_presentation() -> void:
|
||||
profession_prop.visible = false
|
||||
return
|
||||
|
||||
var body_material := StandardMaterial3D.new()
|
||||
body_material.albedo_color = definition.visual_color
|
||||
body_material.roughness = 0.82
|
||||
body_mesh.material_override = body_material
|
||||
left_arm.material_override = body_material
|
||||
right_arm.material_override = body_material
|
||||
AnimalAppearance.set_profession_color(self, definition.visual_color)
|
||||
|
||||
var prop_material := StandardMaterial3D.new()
|
||||
prop_material.albedo_color = definition.prop_color
|
||||
|
||||
@@ -1,70 +1,9 @@
|
||||
[gd_scene load_steps=24 format=3 uid="uid://dhxxyprqflotq"]
|
||||
[gd_scene load_steps=13 format=3 uid="uid://dhxxyprqflotq"]
|
||||
|
||||
[ext_resource type="Script" uid="uid://bha8uf40p3sa0" path="res://player/npc/NpcVisual.gd" id="1_ixfoq"]
|
||||
|
||||
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_d844k"]
|
||||
|
||||
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_d844k"]
|
||||
albedo_color = Color(0.46, 0.58, 0.33, 1)
|
||||
roughness = 0.82
|
||||
|
||||
[sub_resource type="CylinderMesh" id="CylinderMesh_body"]
|
||||
material = SubResource("StandardMaterial3D_d844k")
|
||||
top_radius = 0.28
|
||||
bottom_radius = 0.42
|
||||
height = 0.9
|
||||
|
||||
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_skin"]
|
||||
albedo_color = Color(0.84, 0.61, 0.39, 1)
|
||||
roughness = 0.88
|
||||
|
||||
[sub_resource type="SphereMesh" id="SphereMesh_head"]
|
||||
material = SubResource("StandardMaterial3D_skin")
|
||||
radius = 0.29
|
||||
height = 0.56
|
||||
|
||||
[sub_resource type="CylinderMesh" id="CylinderMesh_hair"]
|
||||
top_radius = 0.24
|
||||
bottom_radius = 0.29
|
||||
height = 0.18
|
||||
|
||||
[sub_resource type="CylinderMesh" id="CylinderMesh_arm"]
|
||||
material = SubResource("StandardMaterial3D_d844k")
|
||||
top_radius = 0.095
|
||||
bottom_radius = 0.11
|
||||
height = 0.58
|
||||
|
||||
[sub_resource type="SphereMesh" id="SphereMesh_hand"]
|
||||
material = SubResource("StandardMaterial3D_skin")
|
||||
radius = 0.12
|
||||
height = 0.22
|
||||
|
||||
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_pants"]
|
||||
albedo_color = Color(0.16, 0.22, 0.25, 1)
|
||||
roughness = 0.9
|
||||
|
||||
[sub_resource type="CapsuleMesh" id="CapsuleMesh_leg"]
|
||||
material = SubResource("StandardMaterial3D_pants")
|
||||
radius = 0.115
|
||||
height = 0.62
|
||||
|
||||
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_boot"]
|
||||
albedo_color = Color(0.13, 0.075, 0.04, 1)
|
||||
roughness = 0.95
|
||||
|
||||
[sub_resource type="BoxMesh" id="BoxMesh_foot"]
|
||||
material = SubResource("StandardMaterial3D_boot")
|
||||
size = Vector3(0.23, 0.15, 0.36)
|
||||
|
||||
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_eye"]
|
||||
albedo_color = Color(0.06, 0.045, 0.035, 1)
|
||||
roughness = 0.8
|
||||
|
||||
[sub_resource type="SphereMesh" id="SphereMesh_eye"]
|
||||
material = SubResource("StandardMaterial3D_eye")
|
||||
radius = 0.035
|
||||
height = 0.06
|
||||
|
||||
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_food"]
|
||||
albedo_color = Color(0.38, 0.22, 0.08, 1)
|
||||
roughness = 0.9
|
||||
@@ -139,62 +78,29 @@ shape = SubResource("CapsuleShape3D_d844k")
|
||||
|
||||
[node name="MeshInstance3D" type="MeshInstance3D" parent="."]
|
||||
position = Vector3(0, 1.05, 0)
|
||||
mesh = SubResource("CylinderMesh_body")
|
||||
|
||||
[node name="Head" type="MeshInstance3D" parent="."]
|
||||
position = Vector3(0, 1.68, 0)
|
||||
mesh = SubResource("SphereMesh_head")
|
||||
|
||||
[node name="Hair" type="MeshInstance3D" parent="."]
|
||||
position = Vector3(0, 1.9, -0.015)
|
||||
mesh = SubResource("CylinderMesh_hair")
|
||||
|
||||
[node name="EyeLeft" type="MeshInstance3D" parent="."]
|
||||
position = Vector3(-0.095, 1.72, 0.274)
|
||||
mesh = SubResource("SphereMesh_eye")
|
||||
|
||||
[node name="EyeRight" type="MeshInstance3D" parent="."]
|
||||
position = Vector3(0.095, 1.72, 0.274)
|
||||
mesh = SubResource("SphereMesh_eye")
|
||||
|
||||
[node name="ArmLeft" type="MeshInstance3D" parent="."]
|
||||
position = Vector3(-0.39, 1.08, 0)
|
||||
rotation = Vector3(0, 0, -0.139626)
|
||||
mesh = SubResource("CylinderMesh_arm")
|
||||
|
||||
[node name="Hand" type="MeshInstance3D" parent="ArmLeft"]
|
||||
position = Vector3(0, -0.35, 0)
|
||||
mesh = SubResource("SphereMesh_hand")
|
||||
position = Vector3(-0.39, 1.3, 0)
|
||||
|
||||
[node name="ArmRight" type="MeshInstance3D" parent="."]
|
||||
position = Vector3(0.39, 1.08, 0)
|
||||
rotation = Vector3(0, 0, 0.139626)
|
||||
mesh = SubResource("CylinderMesh_arm")
|
||||
|
||||
[node name="Hand" type="MeshInstance3D" parent="ArmRight"]
|
||||
position = Vector3(0, -0.35, 0)
|
||||
mesh = SubResource("SphereMesh_hand")
|
||||
position = Vector3(0.39, 1.3, 0)
|
||||
|
||||
[node name="LegLeft" type="MeshInstance3D" parent="."]
|
||||
position = Vector3(-0.18, 0.43, 0)
|
||||
mesh = SubResource("CapsuleMesh_leg")
|
||||
|
||||
[node name="Foot" type="MeshInstance3D" parent="LegLeft"]
|
||||
position = Vector3(0, -0.28, 0.09)
|
||||
mesh = SubResource("BoxMesh_foot")
|
||||
position = Vector3(-0.18, 0.61, 0)
|
||||
|
||||
[node name="LegRight" type="MeshInstance3D" parent="."]
|
||||
position = Vector3(0.18, 0.43, 0)
|
||||
mesh = SubResource("CapsuleMesh_leg")
|
||||
|
||||
[node name="Foot" type="MeshInstance3D" parent="LegRight"]
|
||||
position = Vector3(0, -0.28, 0.09)
|
||||
mesh = SubResource("BoxMesh_foot")
|
||||
position = Vector3(0.18, 0.61, 0)
|
||||
|
||||
[node name="ProfessionProp" type="MeshInstance3D" parent="."]
|
||||
|
||||
[node name="ProfessionLabel" type="Label3D" parent="."]
|
||||
position = Vector3(0, 2.05, 0)
|
||||
position = Vector3(0, 2.8, 0)
|
||||
billboard = 1
|
||||
no_depth_test = true
|
||||
text = "Villager"
|
||||
@@ -223,13 +129,13 @@ mesh = SubResource("CylinderMesh_wood")
|
||||
|
||||
[node name="TaskGlyphRoot" type="Node3D" parent="."]
|
||||
visible = false
|
||||
position = Vector3(0, 2.35, 0)
|
||||
position = Vector3(0, 2.9, 0)
|
||||
|
||||
[node name="TaskGlyph" type="MeshInstance3D" parent="TaskGlyphRoot"]
|
||||
|
||||
[node name="OpportunityConcernRoot" type="Node3D" parent="."]
|
||||
visible = false
|
||||
position = Vector3(-0.4, 2.72, 0)
|
||||
position = Vector3(-0.4, 3.25, 0)
|
||||
|
||||
[node name="EmptyBowl" type="MeshInstance3D" parent="OpportunityConcernRoot"]
|
||||
rotation_degrees = Vector3(72, 0, 0)
|
||||
@@ -250,7 +156,7 @@ mesh = SubResource("SphereMesh_concern")
|
||||
|
||||
[node name="TrustReactionRoot" type="Node3D" parent="."]
|
||||
visible = false
|
||||
position = Vector3(0, 2.72, 0)
|
||||
position = Vector3(0, 3.25, 0)
|
||||
scale = Vector3(0.24, 0.24, 0.24)
|
||||
|
||||
[node name="Halo" type="MeshInstance3D" parent="TrustReactionRoot"]
|
||||
@@ -278,3 +184,6 @@ path_desired_distance = 0.3
|
||||
target_desired_distance = 0.5
|
||||
height = 1.7
|
||||
radius = 0.4
|
||||
|
||||
[node name="Tail" type="MeshInstance3D" parent="."]
|
||||
position = Vector3(0, 0.8, -0.22)
|
||||
|
||||
@@ -14,6 +14,7 @@ var walk_phase := 0.0
|
||||
|
||||
func _ready() -> void:
|
||||
player_body = get_parent() as CharacterBody3D
|
||||
AnimalAppearance.apply_to(self, 0)
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
|
||||
@@ -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)
|
||||
]
|
||||
}
|
||||
|
||||
[rendering]
|
||||
|
||||
anti_aliasing/quality/msaa_3d=2
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://clvjg2p81ay3b
|
||||
@@ -8,6 +8,8 @@ func _initialize() -> void:
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
var viewport := root as Viewport
|
||||
var original_msaa := viewport.msaa_3d
|
||||
var camera := Camera3D.new()
|
||||
camera.current = true
|
||||
root.add_child(camera)
|
||||
@@ -16,13 +18,14 @@ func _run() -> void:
|
||||
await process_frame
|
||||
await physics_frame
|
||||
|
||||
var viewport := root as Viewport
|
||||
var environment: Environment = (
|
||||
(world.get_node("WorldEnvironment") as WorldEnvironment).environment
|
||||
)
|
||||
var light := world.get_node("DirectionalLight3D") as DirectionalLight3D
|
||||
var grass := world.get_node("TerrainRoot/Terrain3D/CozyGrassField") as Node3D
|
||||
var grass_controller := world.get_node("TerrainRoot/GrassInteractionController") as Node
|
||||
var meadow := world.get_node("PainterlyMeadow") as PainterlyMeadow
|
||||
var balanced_meadow := meadow.get_presentation_stats()
|
||||
var world_grass_material := grass.get("mesh_material_override") as ShaderMaterial
|
||||
var world_process_material := grass.get("process_material") as ShaderMaterial
|
||||
var world_sky: Sky = environment.sky
|
||||
@@ -41,6 +44,10 @@ func _run() -> void:
|
||||
+ " (found %.2f)" % balanced_render_scale
|
||||
)
|
||||
)
|
||||
_check(
|
||||
viewport.msaa_3d == mini(original_msaa, Viewport.MSAA_2X),
|
||||
"Balanced should cap authored multisample antialiasing at 2x"
|
||||
)
|
||||
_check(
|
||||
(
|
||||
light.directional_shadow_max_distance <= JajceWorld.BALANCED_SHADOW_DISTANCE
|
||||
@@ -54,6 +61,14 @@ func _run() -> void:
|
||||
world_grass_material == grass_controller.get("grass_material"),
|
||||
"Grass rendering and interaction should share this world's isolated material"
|
||||
)
|
||||
_check(
|
||||
(
|
||||
int(balanced_meadow["multimesh_batch_count"]) == 4
|
||||
and int(balanced_meadow["flower_clump_count"]) in range(1, 1801)
|
||||
and _meadow_has_finite_placements(meadow)
|
||||
),
|
||||
"Meadow batches should retain neutral instance colors, finite placement and their clump budget"
|
||||
)
|
||||
|
||||
_check(
|
||||
world.apply_presentation_quality(JajceWorld.PresentationQuality.HIGH),
|
||||
@@ -63,6 +78,21 @@ func _run() -> void:
|
||||
var high_particle_count := int(grass.get("particle_count"))
|
||||
var high_render_scale := viewport.scaling_3d_scale
|
||||
var high_shadow_distance := light.directional_shadow_max_distance
|
||||
_check(
|
||||
viewport.msaa_3d == original_msaa,
|
||||
"High should restore the authored multisample antialiasing setting"
|
||||
)
|
||||
var high_meadow := meadow.get_presentation_stats()
|
||||
_check(
|
||||
(
|
||||
int(high_meadow["visible_flower_clump_count"]) == int(high_meadow["flower_clump_count"])
|
||||
and (
|
||||
int(balanced_meadow["visible_flower_clump_count"])
|
||||
< int(high_meadow["visible_flower_clump_count"])
|
||||
)
|
||||
),
|
||||
"High should restore every flower clump while Balanced reduces meadow density"
|
||||
)
|
||||
_check(
|
||||
high_particle_count in range(9000, 15001),
|
||||
"High should restore the authored grass population"
|
||||
@@ -94,6 +124,9 @@ func _run() -> void:
|
||||
viewport.scaling_3d_scale <= JajceWorld.LOW_RENDER_SCALE + 0.001,
|
||||
"Low should reduce 3D render resolution"
|
||||
)
|
||||
_check(
|
||||
viewport.msaa_3d == Viewport.MSAA_DISABLED, "Low should disable multisample antialiasing"
|
||||
)
|
||||
_check(
|
||||
(
|
||||
environment.fog_enabled
|
||||
@@ -120,6 +153,18 @@ func _run() -> void:
|
||||
)
|
||||
_check(not grass_controller.is_processing(), "Low should stop grass interaction scans")
|
||||
_check(_not_any_grass_particle_emitting(grass), "Low should stop every grass particle emitter")
|
||||
var low_meadow := meadow.get_presentation_stats()
|
||||
_check(
|
||||
(
|
||||
int(low_meadow["visible_flower_clump_count"]) > 0
|
||||
and (
|
||||
int(low_meadow["visible_flower_clump_count"])
|
||||
< int(balanced_meadow["visible_flower_clump_count"])
|
||||
)
|
||||
and low_meadow["flower_clump_count"] == high_meadow["flower_clump_count"]
|
||||
),
|
||||
"Low should retain a reduced flower population without regenerating placements"
|
||||
)
|
||||
_check(
|
||||
(
|
||||
world_grass_material != null
|
||||
@@ -136,6 +181,7 @@ func _run() -> void:
|
||||
_check(
|
||||
(
|
||||
is_equal_approx(viewport.scaling_3d_scale, high_render_scale)
|
||||
and viewport.msaa_3d == original_msaa
|
||||
and environment.glow_enabled
|
||||
and (environment.volumetric_fog_enabled == world.call("_supports_volumetric_fog"))
|
||||
and environment.adjustment_enabled
|
||||
@@ -153,12 +199,19 @@ func _run() -> void:
|
||||
),
|
||||
"Returning to High should restore grass rendering and processing"
|
||||
)
|
||||
_check(
|
||||
meadow.get_presentation_stats() == high_meadow,
|
||||
"Returning to High should restore the same complete flower batches"
|
||||
)
|
||||
|
||||
var world_base_scale := float(world.get("_authored_render_scale"))
|
||||
world.free()
|
||||
_check(
|
||||
is_equal_approx(viewport.scaling_3d_scale, world_base_scale),
|
||||
"An exiting world should restore the viewport scale it originally owned"
|
||||
(
|
||||
is_equal_approx(viewport.scaling_3d_scale, world_base_scale)
|
||||
and viewport.msaa_3d == original_msaa
|
||||
),
|
||||
"An exiting world should restore the viewport scale and antialiasing it originally owned"
|
||||
)
|
||||
_check(
|
||||
(
|
||||
@@ -183,6 +236,7 @@ func _run() -> void:
|
||||
_check(
|
||||
(
|
||||
fresh_world.get_active_presentation_quality() == JajceWorld.PresentationQuality.BALANCED
|
||||
and viewport.msaa_3d == mini(original_msaa, Viewport.MSAA_2X)
|
||||
and fresh_environment.fog_enabled
|
||||
and not fresh_environment.volumetric_fog_enabled
|
||||
and fresh_grass.visible
|
||||
@@ -205,8 +259,11 @@ func _run() -> void:
|
||||
var original_scale := float(fresh_world.get("_authored_render_scale"))
|
||||
fresh_world.free()
|
||||
_check(
|
||||
is_equal_approx(viewport.scaling_3d_scale, original_scale),
|
||||
"A sequential world should release its viewport-scale ownership"
|
||||
(
|
||||
is_equal_approx(viewport.scaling_3d_scale, original_scale)
|
||||
and viewport.msaa_3d == original_msaa
|
||||
),
|
||||
"A sequential world should release its viewport scale and antialiasing ownership"
|
||||
)
|
||||
|
||||
var older_owner := _create_presentation_only_world(JajceWorld.PresentationQuality.LOW)
|
||||
@@ -216,19 +273,29 @@ func _run() -> void:
|
||||
root.add_child(newer_owner)
|
||||
await process_frame
|
||||
_check(
|
||||
is_equal_approx(float(newer_owner.get("_authored_render_scale")), original_scale),
|
||||
"Concurrent owners should inherit the viewport base, not another owner's reduced scale"
|
||||
(
|
||||
is_equal_approx(float(newer_owner.get("_authored_render_scale")), original_scale)
|
||||
and int(newer_owner.get("_authored_msaa")) == original_msaa
|
||||
),
|
||||
"Concurrent owners should inherit the viewport's base scale and antialiasing"
|
||||
)
|
||||
older_owner.free()
|
||||
_check(
|
||||
is_equal_approx(viewport.scaling_3d_scale, JajceWorld.BALANCED_RENDER_SCALE),
|
||||
"A stale owner exiting should not overwrite the active owner's scale"
|
||||
(
|
||||
is_equal_approx(viewport.scaling_3d_scale, JajceWorld.BALANCED_RENDER_SCALE)
|
||||
and viewport.msaa_3d == mini(original_msaa, Viewport.MSAA_2X)
|
||||
),
|
||||
"A stale owner exiting should preserve the active owner's scale and antialiasing"
|
||||
)
|
||||
newer_owner.free()
|
||||
_check(
|
||||
is_equal_approx(viewport.scaling_3d_scale, original_scale),
|
||||
"The final viewport owner should restore the original scale on exit"
|
||||
(
|
||||
is_equal_approx(viewport.scaling_3d_scale, original_scale)
|
||||
and viewport.msaa_3d == original_msaa
|
||||
),
|
||||
"The final viewport owner should restore the original scale and antialiasing on exit"
|
||||
)
|
||||
await _check_msaa_previous_owner_and_external_change(viewport)
|
||||
|
||||
var main_scene: Node = load("res://main.tscn").instantiate()
|
||||
var time_dial := main_scene.get_node("UI/TimeDial") as Control
|
||||
@@ -264,6 +331,32 @@ func _all_grass_particles_emitting(grass: Node3D) -> bool:
|
||||
return true
|
||||
|
||||
|
||||
func _meadow_has_finite_placements(meadow: PainterlyMeadow) -> bool:
|
||||
var terrain := meadow.get_node(meadow.terrain_path) as Terrain3D
|
||||
# The dummy headless renderer returns black for every instance-color readback.
|
||||
var can_read_instance_colors := DisplayServer.get_name() != "headless"
|
||||
for child in meadow.get_children():
|
||||
var batch := child as MultiMeshInstance3D
|
||||
if batch == null or batch.multimesh == null:
|
||||
return false
|
||||
if not batch.multimesh.use_colors or not batch.multimesh.use_custom_data:
|
||||
return false
|
||||
for index in batch.multimesh.instance_count:
|
||||
if (
|
||||
can_read_instance_colors
|
||||
and not batch.multimesh.get_instance_color(index).is_equal_approx(Color.WHITE)
|
||||
):
|
||||
return false
|
||||
var placement := batch.multimesh.get_instance_transform(index)
|
||||
if not placement.is_finite():
|
||||
return false
|
||||
var point := batch.to_global(placement.origin)
|
||||
var height := terrain.data.get_height(point)
|
||||
if is_nan(height) or absf(point.y - height) > 0.025:
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
func _create_presentation_only_world(quality: int) -> JajceWorld:
|
||||
var world: JajceWorld = load("res://world/jajce/JajceWorld.tscn").instantiate()
|
||||
# Stable simulation-facing IDs deliberately allow only one loaded authority
|
||||
@@ -273,6 +366,33 @@ func _create_presentation_only_world(quality: int) -> JajceWorld:
|
||||
return world
|
||||
|
||||
|
||||
func _check_msaa_previous_owner_and_external_change(viewport: Viewport) -> void:
|
||||
var original_msaa := viewport.msaa_3d
|
||||
viewport.msaa_3d = Viewport.MSAA_8X
|
||||
var previous_owner := _create_presentation_only_world(JajceWorld.PresentationQuality.LOW)
|
||||
root.add_child(previous_owner)
|
||||
await process_frame
|
||||
var active_owner := _create_presentation_only_world(JajceWorld.PresentationQuality.HIGH)
|
||||
root.add_child(active_owner)
|
||||
await process_frame
|
||||
_check(
|
||||
viewport.msaa_3d == Viewport.MSAA_8X,
|
||||
"A High owner should inherit authored antialiasing even when the previous owner uses Low"
|
||||
)
|
||||
active_owner.free()
|
||||
_check(
|
||||
viewport.msaa_3d == Viewport.MSAA_DISABLED,
|
||||
"An exiting active owner should restore the surviving previous owner's antialiasing"
|
||||
)
|
||||
viewport.msaa_3d = Viewport.MSAA_2X
|
||||
previous_owner.free()
|
||||
_check(
|
||||
viewport.msaa_3d == Viewport.MSAA_2X,
|
||||
"Releasing the final owner should preserve an external antialiasing change"
|
||||
)
|
||||
viewport.msaa_3d = original_msaa
|
||||
|
||||
|
||||
func _not_any_grass_particle_emitting(grass: Node3D) -> bool:
|
||||
var particles: Array = grass.get("particle_nodes")
|
||||
for value in particles:
|
||||
|
||||
@@ -20,6 +20,7 @@ func _run() -> void:
|
||||
|
||||
var manager = main_scene.get_node("SimulationManager")
|
||||
manager.set_process(false)
|
||||
_check_village_tree_bindings(main_scene, manager)
|
||||
var cluster := main_scene.get_node(CLUSTER_PATH) as RiverbankResourceCluster
|
||||
var berry := cluster.get_node("ResourceAnchors/BerryBush_River_02") as ResourceNode
|
||||
var tree := cluster.get_node("ResourceAnchors/Tree_River_Resource_01") as ResourceNode
|
||||
@@ -120,6 +121,34 @@ func _run() -> void:
|
||||
_finish()
|
||||
|
||||
|
||||
func _check_village_tree_bindings(main_scene: Node, manager: Node) -> void:
|
||||
var snapshot: String = manager.serialize_state()
|
||||
for tree_name in ["Tree_01", "Tree_02", "Tree_North_Outskirts_01", "Tree_South_Edge_01"]:
|
||||
var tree := (
|
||||
main_scene.get_node("JajceWorld/WorldObjects/ResourceNodes/" + tree_name)
|
||||
as ResourceNode
|
||||
)
|
||||
var visual := tree.get_node("Visual/TreePresentation") as HarvestableTreePresentation
|
||||
_check(
|
||||
visual.resource_node == tree,
|
||||
"Village tree art should bind the existing finite resource"
|
||||
)
|
||||
while tree.state.can_extract():
|
||||
manager.harvest_resource_node(tree)
|
||||
_check(visual.is_stump_visible(), "Harvesting the real village tree should leave a stump")
|
||||
_check(manager.restore_state_from_json(snapshot), "Village tree harvest review should restore")
|
||||
for tree_name in ["Tree_01", "Tree_02", "Tree_North_Outskirts_01", "Tree_South_Edge_01"]:
|
||||
var visual := (
|
||||
main_scene.get_node(
|
||||
"JajceWorld/WorldObjects/ResourceNodes/" + tree_name + "/Visual/TreePresentation"
|
||||
)
|
||||
as HarvestableTreePresentation
|
||||
)
|
||||
_check(
|
||||
visual.get_visible_canopy_count() == 4, "Restore rebuilds each full village tree crown"
|
||||
)
|
||||
|
||||
|
||||
func _check_initial_presentation(
|
||||
berry_visual: StylizedBerryPatch, tree_visual: HarvestableTreePresentation
|
||||
) -> void:
|
||||
|
||||
@@ -535,7 +535,7 @@ func _run() -> void:
|
||||
(
|
||||
main_scene.has_node("Player/Visual/Body")
|
||||
and main_scene.has_node("Player/Visual/Head")
|
||||
and main_scene.has_node("Player/Visual/Scarf")
|
||||
and main_scene.get_node("Player/Visual").get_meta("animal_species") == &"fox"
|
||||
),
|
||||
"Player should use the same readable multi-part visual language as villagers"
|
||||
)
|
||||
|
||||
@@ -63,10 +63,13 @@ func _run() -> void:
|
||||
|
||||
var river_surface := world.get_node("WaterRoot/RiverSurface") as Node3D
|
||||
_check_ribbon_tracks_terrain(
|
||||
terrain, river_surface.get_node("RiverRibbon") as MeshInstance3D, "Downstream water"
|
||||
terrain, river_surface.get_node("RiverRibbon") as MeshInstance3D, "Downstream water", false
|
||||
)
|
||||
_check_ribbon_tracks_terrain(
|
||||
terrain, river_surface.get_node("UpperRiverRibbon") as MeshInstance3D, "Upper-stream water"
|
||||
terrain,
|
||||
river_surface.get_node("UpperRiverRibbon") as MeshInstance3D,
|
||||
"Upper-stream water",
|
||||
true
|
||||
)
|
||||
|
||||
print(
|
||||
@@ -83,23 +86,98 @@ func _terrain_height(terrain: Terrain3D, world_x: float, world_z: float) -> floa
|
||||
|
||||
|
||||
func _check_ribbon_tracks_terrain(
|
||||
terrain: Terrain3D, ribbon: MeshInstance3D, label: String
|
||||
terrain: Terrain3D, ribbon: MeshInstance3D, label: String, is_upper: bool
|
||||
) -> void:
|
||||
_check(ribbon != null and ribbon.mesh is ArrayMesh, "%s ribbon should be generated" % label)
|
||||
if ribbon == null or not ribbon.mesh is ArrayMesh:
|
||||
return
|
||||
var arrays := ribbon.mesh.surface_get_arrays(0)
|
||||
var vertices: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
|
||||
var uvs: PackedVector2Array = arrays[Mesh.ARRAY_TEX_UV]
|
||||
_check(not vertices.is_empty(), "%s ribbon should contain vertices" % label)
|
||||
for vertex_index in range(0, vertices.size(), maxi(1, vertices.size() / 12)):
|
||||
if vertices.is_empty() or uvs.size() != vertices.size():
|
||||
failures.append("%s ribbon requires a complete coordinate profile" % label)
|
||||
return
|
||||
var start_z := (
|
||||
JajceWatercourse.UPPER_STREAM_START_Z if is_upper else JajceWatercourse.DOWNSTREAM_START_Z
|
||||
)
|
||||
var end_z := (
|
||||
JajceWatercourse.UPPER_STREAM_END_Z if is_upper else JajceWatercourse.DOWNSTREAM_END_Z
|
||||
)
|
||||
_check(
|
||||
(
|
||||
absf(ribbon.to_global(vertices[0]).z - start_z) < 0.03
|
||||
and absf(ribbon.to_global(vertices[-1]).z - end_z) < 0.03
|
||||
),
|
||||
"%s ribbon should span its entire authored stream length" % label
|
||||
)
|
||||
var pool := ribbon.get_parent().get_node("PlungePool") as MeshInstance3D
|
||||
var pool_mesh := pool.mesh as PlaneMesh
|
||||
var pool_radii := pool_mesh.size * Vector2(pool.scale.x, pool.scale.z) * 0.5
|
||||
var min_depth := INF
|
||||
var max_depth := -INF
|
||||
for vertex_index in vertices.size():
|
||||
var world_vertex := ribbon.to_global(vertices[vertex_index])
|
||||
var world_z := lerpf(start_z, end_z, uvs[vertex_index].y)
|
||||
var center_x := (
|
||||
JajceWatercourse.upper_stream_center_x(world_z)
|
||||
if is_upper
|
||||
else JajceWatercourse.downstream_center_x(world_z)
|
||||
)
|
||||
var half_width := (
|
||||
JajceWatercourse.upper_stream_half_width(world_z)
|
||||
if is_upper
|
||||
else JajceWatercourse.downstream_half_width(world_z)
|
||||
)
|
||||
if not is_upper:
|
||||
var pool_progress := (world_z - pool.global_position.z) / pool_radii.y
|
||||
if absf(pool_progress) < 1.0:
|
||||
half_width = maxf(
|
||||
half_width, pool_radii.x * sqrt(1.0 - pool_progress * pool_progress)
|
||||
)
|
||||
var center_height := _terrain_height(terrain, center_x, world_z)
|
||||
var terrain_height := terrain.data.get_height(world_vertex)
|
||||
if is_nan(terrain_height):
|
||||
if (
|
||||
not world_vertex.is_finite()
|
||||
or not is_finite(terrain_height)
|
||||
or not is_finite(center_height)
|
||||
):
|
||||
failures.append("%s ribbon sampled outside Terrain3D" % label)
|
||||
return
|
||||
if absf(world_vertex.y - terrain_height - JajceWatercourse.WATER_SURFACE_OFFSET) > 0.03:
|
||||
failures.append("%s ribbon should hug the carved terrain" % label)
|
||||
if absf(world_vertex.y - center_height - JajceWatercourse.WATER_SURFACE_OFFSET) > 0.03:
|
||||
failures.append("%s cross-section should follow its actual carved centerline" % label)
|
||||
return
|
||||
if (
|
||||
absf(world_vertex.z - world_z) > 0.03
|
||||
or absf(world_vertex.x - center_x) > half_width + 0.03
|
||||
):
|
||||
failures.append(
|
||||
"%s ribbon should stay inside the authored stream/pool footprint" % label
|
||||
)
|
||||
return
|
||||
var depth := world_vertex.y - terrain_height
|
||||
min_depth = minf(min_depth, depth)
|
||||
max_depth = maxf(max_depth, depth)
|
||||
if depth < -0.03 or depth > JajceWatercourse.WATER_SURFACE_OFFSET + 0.03:
|
||||
failures.append("%s surface should remain shallow and above the channel bed" % label)
|
||||
return
|
||||
var across := uvs[vertex_index].x
|
||||
if is_zero_approx(across) or is_equal_approx(across, 1.0):
|
||||
var edge_distance := absf(world_vertex.x - center_x)
|
||||
if absf(edge_distance - half_width) > 0.03 and absf(depth) > 0.03:
|
||||
failures.append(
|
||||
"%s edges must reach the authored extent or the actual shoreline" % label
|
||||
)
|
||||
return
|
||||
if (
|
||||
(is_zero_approx(across) and world_vertex.x >= center_x)
|
||||
or (is_equal_approx(across, 1.0) and world_vertex.x <= center_x)
|
||||
):
|
||||
failures.append(
|
||||
"%s water must retain its authored centerline inside both banks" % label
|
||||
)
|
||||
return
|
||||
print("[TEST] %s depth bounds | min=%.3f max=%.3f" % [label, min_depth, max_depth])
|
||||
|
||||
|
||||
func _check(condition: bool, message: String) -> void:
|
||||
|
||||
@@ -89,6 +89,11 @@ func _run() -> void:
|
||||
"Village center should include a few oversized ambient butterflies"
|
||||
)
|
||||
var grass_field := world.get_node("TerrainRoot/Terrain3D/CozyGrassField")
|
||||
var grass_mesh := grass_field.get("mesh") as ArrayMesh
|
||||
_check(
|
||||
grass_mesh != null and grass_mesh.get_faces().size() == 105,
|
||||
"Meadow grass must reuse the 35-triangle Blender tuft"
|
||||
)
|
||||
var grass_material := grass_field.get("mesh_material_override") as ShaderMaterial
|
||||
var grass_process := grass_field.get("process_material") as ShaderMaterial
|
||||
_check(
|
||||
@@ -112,6 +117,10 @@ func _run() -> void:
|
||||
"Grass placement should stay short enough to preserve villagers and paths"
|
||||
)
|
||||
var grass_controller := world.get_node("TerrainRoot/GrassInteractionController")
|
||||
_check(
|
||||
grass_material.get_shader_parameter("clearing_count") == 4,
|
||||
"Grass must leave the four authored walking paths clear"
|
||||
)
|
||||
_check(
|
||||
grass_controller.get("grass_material") == grass_material,
|
||||
"Grass interaction should update the same material rendered by Terrain3D particles"
|
||||
@@ -189,7 +198,7 @@ func _run() -> void:
|
||||
if not tree.has_node("Canopy"):
|
||||
continue
|
||||
var canopy := tree.get_node("Canopy") as MeshInstance3D
|
||||
var material := canopy.mesh.material as ShaderMaterial
|
||||
var material := canopy.get_active_material(0) as ShaderMaterial
|
||||
tree_wind_is_bounded = (
|
||||
tree_wind_is_bounded
|
||||
and material != null
|
||||
@@ -240,6 +249,8 @@ func _run() -> void:
|
||||
upper_river_ribbon.mesh is ArrayMesh,
|
||||
"Waterfall shelf should expose a terrain-following upper stream"
|
||||
)
|
||||
_check_water_cross_sections(river_ribbon)
|
||||
_check_water_cross_sections(upper_river_ribbon)
|
||||
_check(
|
||||
world.has_node("WaterRoot/RiverSurface/PlungePool"),
|
||||
"River should widen into a readable plunge pool beneath the waterfall"
|
||||
@@ -261,6 +272,7 @@ func _run() -> void:
|
||||
),
|
||||
"Plunge-pool water should follow the carved basin height"
|
||||
)
|
||||
_check_pool_outlet(river_ribbon, plunge_pool, terrain)
|
||||
var environment: Environment = (
|
||||
(world.get_node("WorldEnvironment") as WorldEnvironment).environment
|
||||
)
|
||||
@@ -619,6 +631,44 @@ func _run() -> void:
|
||||
quit(1)
|
||||
|
||||
|
||||
func _check_water_cross_sections(ribbon: MeshInstance3D) -> void:
|
||||
var arrays := ribbon.mesh.surface_get_arrays(0)
|
||||
var vertices: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
|
||||
var uvs: PackedVector2Array = arrays[Mesh.ARRAY_TEX_UV]
|
||||
var row_height := 0.0
|
||||
for index in vertices.size():
|
||||
if is_zero_approx(uvs[index].x):
|
||||
row_height = vertices[index].y
|
||||
_check(
|
||||
is_equal_approx(vertices[index].y, row_height),
|
||||
"Water cross-sections should remain level instead of climbing either terrain bank"
|
||||
)
|
||||
|
||||
|
||||
func _check_pool_outlet(ribbon: MeshInstance3D, pool: MeshInstance3D, terrain: Terrain3D) -> void:
|
||||
var vertices: PackedVector3Array = ribbon.mesh.surface_get_arrays(0)[Mesh.ARRAY_VERTEX]
|
||||
var start := ribbon.to_global(vertices[0])
|
||||
var pool_mesh := pool.mesh as PlaneMesh
|
||||
var radii := pool_mesh.size * Vector2(pool.scale.x, pool.scale.z) * 0.5
|
||||
var progress := (start.z - pool.global_position.z) / radii.y
|
||||
var expected_edge := pool.global_position.x - radii.x * sqrt(1.0 - progress * progress)
|
||||
var meets_ellipse := absf(start.x - expected_edge) < 0.03
|
||||
var meets_shore := (
|
||||
start.x >= expected_edge
|
||||
and start.x < pool.global_position.x
|
||||
and absf(start.y - terrain.data.get_height(start)) < 0.03
|
||||
)
|
||||
_check(
|
||||
(meets_ellipse or meets_shore) and absf(start.y - pool.global_position.y) < 0.03,
|
||||
"River outlet should meet the pool ellipse or its terrain shoreline at the same surface height"
|
||||
)
|
||||
var material := pool_mesh.surface_get_material(0) as ShaderMaterial
|
||||
_check(
|
||||
is_equal_approx(float(material.get_shader_parameter("pool_outlet_z")), start.z),
|
||||
"Pool transparency should end where the river takes over to avoid coplanar overlap"
|
||||
)
|
||||
|
||||
|
||||
func _wait_for_navigation_map(navigation_map: RID) -> void:
|
||||
for attempt in 30:
|
||||
if (
|
||||
|
||||
@@ -26,17 +26,28 @@ func _run() -> void:
|
||||
var trust_reaction_root: Node3D = visual.get_node("TrustReactionRoot")
|
||||
_check(not trust_reaction_root.visible, "A newly spawned visual should not replay reactions")
|
||||
_check(
|
||||
body.material_override.albedo_color == profession_definition.visual_color,
|
||||
"NPC body color should come from its profession definition"
|
||||
(
|
||||
body.material_override is ShaderMaterial
|
||||
and (
|
||||
body.material_override.get_shader_parameter("coat_color")
|
||||
== profession_definition.visual_color
|
||||
)
|
||||
),
|
||||
"NPC cel material should retain its authoritative profession color"
|
||||
)
|
||||
_check(
|
||||
(
|
||||
body.mesh is CylinderMesh
|
||||
body.mesh is ArrayMesh
|
||||
and visual.has_node("Head")
|
||||
and visual.has_node("ArmLeft")
|
||||
and visual.has_node("LegRight/Foot")
|
||||
and visual.get_node("Tail").mesh is ArrayMesh
|
||||
),
|
||||
"NPC presentation should use a readable multi-part villager silhouette"
|
||||
"NPC presentation should use the Blender animal body and tail"
|
||||
)
|
||||
var original_species: StringName = visual.get_meta("animal_species")
|
||||
_check(
|
||||
visual.get_node("TaskGlyphRoot").position.y >= 2.9,
|
||||
"Task cues must sit above the animal cast's tall ears"
|
||||
)
|
||||
_check(
|
||||
(
|
||||
@@ -184,6 +195,10 @@ func _run() -> void:
|
||||
_check(view.reload_npc_visual(npc.id), "Visual should reload")
|
||||
var reloaded: Node3D = view.active_npc_visuals[npc.id]
|
||||
reloaded.set_physics_process(false)
|
||||
_check(
|
||||
reloaded.get_meta("animal_species") == original_species,
|
||||
"Reload must derive the same animal identity without saved appearance or RNG"
|
||||
)
|
||||
_check(
|
||||
reloaded.global_position.is_equal_approx(authoritative_position),
|
||||
"Reloaded visual should use authoritative position"
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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()
|
||||
@@ -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"]))
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bqd5coilksebi
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bqcj3uman431w
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://blkknmqjyo6uj
|
||||
@@ -1,16 +1,18 @@
|
||||
[gd_scene load_steps=9 format=3]
|
||||
[gd_scene load_steps=10 format=3]
|
||||
|
||||
[sub_resource type="StandardMaterial3D" id="Material_deck"]
|
||||
albedo_color = Color(0.61, 0.53, 0.42, 1)
|
||||
roughness = 0.94
|
||||
[ext_resource type="Shader" path="res://world/presentation/materials/storybook_surface.gdshader" id="story_surface"]
|
||||
|
||||
[sub_resource type="ShaderMaterial" id="Material_deck"]
|
||||
shader = ExtResource("story_surface")
|
||||
shader_parameter/albedo = Color(0.61, 0.53, 0.42, 1)
|
||||
|
||||
[sub_resource type="BoxMesh" id="Mesh_deck"]
|
||||
material = SubResource("Material_deck")
|
||||
size = Vector3(15.2, 0.32, 3.15)
|
||||
|
||||
[sub_resource type="StandardMaterial3D" id="Material_stone"]
|
||||
albedo_color = Color(0.48, 0.47, 0.41, 1)
|
||||
roughness = 0.98
|
||||
[sub_resource type="ShaderMaterial" id="Material_stone"]
|
||||
shader = ExtResource("story_surface")
|
||||
shader_parameter/albedo = Color(0.48, 0.47, 0.41, 1)
|
||||
|
||||
[sub_resource type="BoxMesh" id="Mesh_abutment"]
|
||||
material = SubResource("Material_stone")
|
||||
@@ -20,9 +22,9 @@ size = Vector3(2.25, 1.45, 4.2)
|
||||
material = SubResource("Material_stone")
|
||||
size = Vector3(1.05, 1.65, 3.7)
|
||||
|
||||
[sub_resource type="StandardMaterial3D" id="Material_rail"]
|
||||
albedo_color = Color(0.2, 0.115, 0.055, 1)
|
||||
roughness = 0.92
|
||||
[sub_resource type="ShaderMaterial" id="Material_rail"]
|
||||
shader = ExtResource("story_surface")
|
||||
shader_parameter/albedo = Color(0.2, 0.115, 0.055, 1)
|
||||
|
||||
[sub_resource type="BoxMesh" id="Mesh_rail"]
|
||||
material = SubResource("Material_rail")
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://6llj4y8dy0jm
|
||||