Files
Project-M/.claude/skills/art-dev/references/blender-cookbook.md
T
kronic 011037e70c Docs: art-dev cookbook — locked lighting + posed-static hero bake
'Light is territory' pool lighting (reads top-down; vertical god-rays don't),
undulating seabed mesh, Play-mode ambiance script, and the apply-armature +
visual-transform static hero-bake recipe for style-proof placement.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 21:31:58 -07:00

270 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Blender cookbook (blendermcp, Blender 5.1) — LANTERN art
Copy-paste-ready snippets, all verified this-machine via `mcp__blender__execute_blender_code`. **Every runtime script sets `PROJ` first** (Blender runs outside the harness — resolve the absolute repo root at runtime; do NOT commit a machine path into this file's *usage*, the `<...>` below is a placeholder):
```python
PROJ = r"<absolute repo root, e.g. C:\Dev\Unity\M\Project-M>"
ATLAS = PROJ + r"\Assets\_Project\Art\Palette\PaletteAtlas.png"
SRC = PROJ + r"\ArtSource\Blender" # per-asset master .blend + exports live here
```
General gotchas: `get_scene_info`/`get_viewport_screenshot` need a `user_prompt` arg. `Date.now`/`random` are fine in Blender Python (unlike Workflow scripts) — seed `random` for reproducibility. Blender 5.1 = EEVEE Next; `action.fcurves` is gone (slotted actions).
## 1. Scene setup (units + murk world + lights + camera + view transform)
```python
import bpy
sc = bpy.context.scene
sc.unit_settings.system='METRIC'; sc.unit_settings.scale_length=1.0; sc.unit_settings.length_unit='METERS'
try: sc.render.engine='BLENDER_EEVEE_NEXT'
except Exception: sc.render.engine='BLENDER_EEVEE'
sc.view_settings.view_transform='Standard'; sc.view_settings.look='None' # judge HUE truly; AgX whitens bright emission
# murk world (deep cold blue, not black)
w = sc.world or bpy.data.worlds.new("World"); sc.world=w; w.use_nodes=True
bg=w.node_tree.nodes.get("Background"); bg.inputs[0].default_value=(0.015,0.03,0.05,1.0); bg.inputs[1].default_value=0.3
# warm KEY (gold = our light) + faint cold GLOAM fill (the deep)
def area_light(name, loc, color, energy, size=1.3):
ld=bpy.data.lights.new(name,'AREA'); ld.size=size; ld.color=color; ld.energy=energy
ob=bpy.data.objects.new(name,ld); ob.location=loc; sc.collection.objects.link(ob); return ob
area_light("KeyWarm",(1.9,-2.2,2.4),(1.0,0.75,0.45),220)
area_light("GloamFill",(-2.0,1.8,1.5),(0.22,0.55,0.8),120)
```
- A default startup scene has objects `Cube`/`Light`/`Camera`. `bpy.ops.wm.read_homefile()` (NOT `use_empty=True` — an empty scene breaks the FBX importer's context). Delete `Cube` after.
- For a self-emissive subject (wisp/flora), drop key energy (~3080) + world (~0.25) so the emission reads as the light source.
## 2. Palette + emissive materials (the ONLY materials — color by UV placement)
```python
def palette_mat(name="M_Palette_Atlas"):
m=bpy.data.materials.get(name)
if m: return m
m=bpy.data.materials.new(name); m.use_nodes=True; nt=m.node_tree; nt.nodes.clear()
o=nt.nodes.new("ShaderNodeOutputMaterial"); o.location=(400,0)
b=nt.nodes.new("ShaderNodeBsdfPrincipled"); b.location=(120,0); b.inputs["Roughness"].default_value=0.65
t=nt.nodes.new("ShaderNodeTexImage"); t.location=(-260,0)
t.image=bpy.data.images.load(ATLAS, check_existing=True); t.interpolation='Closest' # crisp flat bands
nt.links.new(t.outputs["Color"],b.inputs["Base Color"]); nt.links.new(b.outputs["BSDF"],o.inputs["Surface"])
return m
def emissive(name, color, strength): # warm(1.0,0.66,0.28) true | gloam(0.1,0.85,0.34) false | teal(0.12,0.62,0.85) ambient
m=bpy.data.materials.get(name)
if m: return m
m=bpy.data.materials.new(name); m.use_nodes=True; nt=m.node_tree; nt.nodes.clear()
o=nt.nodes.new("ShaderNodeOutputMaterial"); e=nt.nodes.new("ShaderNodeEmission")
e.inputs["Color"].default_value=(*color,1.0); e.inputs["Strength"].default_value=strength
nt.links.new(e.outputs["Emission"],o.inputs["Surface"]); return m
```
⚠ Emission **strength 46 + AgX** clips to white and desaturates. On `Standard`, keep strength ~1.53 and use a saturated color (one channel high, others low) so it doesn't clip to white/cyan. URP bloom supplies the glow halo in-engine (Blender preview has none).
## 3. Palette atlas band map + cell-UV (row 0 = BOTTOM)
The atlas is an 8×8 grid; each face samples ONE flat cell. Cell centre UV:
```python
def cell(cx, cy): return ((cx+0.5)/8.0, (cy+0.5)/8.0) # cx,cy in 0..7
```
| Row (from bottom) | Band | Use |
|---|---|---|
| 6 | pure grey ramp (col0 dark → col7 light) | neutral hard-surface |
| 5 | warm gold/amber → cream | **true light / ours** |
| 4 | dark → bright red | lure-red / bait |
| 3 | dark navy → bright blue | **gloam (cold)** |
| 2 | dark → bright corpse-green | **gloam (cold)** |
| 01 | warm brown/rust → amber/brass | warm metal |
Handy picks: grey body `cell(3,6)`, brass trim `cell(5,0)`, steel `cell(2,6)`, dark-gloam accent `cell(1,2)`.
## 4. Faceted primitive + UV-placement helper
```python
import bmesh
def new_obj(name, bm, mat, uv, loc=(0,0,0), rot=None):
me=bpy.data.meshes.new(name); ob=bpy.data.objects.new(name,me); bpy.context.collection.objects.link(ob)
L=bm.loops.layers.uv.verify()
for f in bm.faces:
f.smooth=False # FLAT / faceted shading (the style)
for l in f.loops: l[L].uv=uv # every loop -> one cell centre = flat colour
bm.to_mesh(me); bm.free(); me.materials.append(mat); ob.location=loc
if rot: ob.rotation_euler=rot
return ob
# examples
bm=bmesh.new(); bmesh.ops.create_cube(bm,size=1.0) # box
bm=bmesh.new(); bmesh.ops.create_icosphere(bm,subdivisions=1,radius=0.15) # faceted orb (20 faces)
bm=bmesh.new(); bmesh.ops.create_cone(bm,cap_ends=True,segments=8,radius1=0.08,radius2=0.0,depth=0.3) # taper/cylinder
bmesh.ops.bevel(bm,geom=list(bm.edges)+list(bm.verts),offset=0.045,segments=1,affect='EDGES',clamp_overlap=True)
bmesh.ops.inset_individual(bm,faces=[f for f in bm.faces if max(abs(c) for c in f.normal)>0.95],thickness=0.075,depth=-0.028) # recessed panels
```
Report tris: `me.calc_loop_triangles(); len(me.loop_triangles)`.
## 5. Synty character FBX import (Blender 5.1 workaround)
Plain `import_scene.fbx` on a combined Synty character FBX FAILS on 5.1 (`mode_set('EDIT') Context missing active object`, then `pose.bones['Root'] KeyError`). Fix = window-override + these flags; do NOT force `object=`/`active_object=` (fights the importer's armature activation → the 'Root' error):
```python
bpy.ops.wm.read_homefile() # need a valid active object for the importer
fbx = PROJ + r"\Assets\Synty\PolygonSciFiSpace\Models\Characters.fbx"
win=bpy.context.window_manager.windows[0]; scr=win.screen
area=next(a for a in scr.areas if a.type=='VIEW_3D'); region=next(r for r in area.regions if r.type=='WINDOW')
with bpy.context.temp_override(window=win, screen=scr, area=area, region=region):
bpy.ops.import_scene.fbx(filepath=fbx, ignore_leaf_bones=True, automatic_bone_orientation=False)
```
Bodies live INSIDE the combined FBX (20 chars on one rig): `SM_Chr_SpaceSoldier_Male_01` (armored suit base), `SM_Chr_Crew_Male_01`/`_Junker_Male_01` (lean → drowner/enemy bases), `SM_Chr_..._Armour_01` (plate shell), EVA dome parts are separate FBX. Cull the 19 you don't want; keep the one body + the `Armature`. We are **licensed to modify Synty** — but never overwrite the pack FBX (GUID refs).
Synty bone names: `Root, Hips, Spine_01/02/03, Neck, Head, Eyes, Clavicle_L/R, Shoulder_L/R (upper arm), Elbow_L/R, Hand_L/R, UpperLeg/LowerLeg/Ankle/Ball _L/R`.
## 6. Normalize scale + feet-at-floor, apply for export
```python
from mathutils import Vector
def world_bounds(objs):
mn=Vector((1e9,)*3); mx=Vector((-1e9,)*3)
for ob in objs:
for c in ob.bound_box:
wv=ob.matrix_world@Vector(c); mn=Vector((min(mn[i],wv[i]) for i in range(3))); mx=Vector((max(mx[i],wv[i]) for i in range(3)))
return mn,mx
mn,mx=world_bounds(body_parts); root=arm or body_parts[0]
s=1.8/(mx.z-mn.z); root.scale=[v*s for v in root.scale]; bpy.context.view_layer.update()
mn,mx=world_bounds(body_parts); root.location.z-=mn.z # drop feet to z=0
# before export of a static: origin to geometry, apply transforms
bpy.ops.object.origin_set(type='ORIGIN_GEOMETRY', center='BOUNDS')
bpy.ops.object.transform_apply(location=False, rotation=True, scale=True)
```
## 7. Pose bones (rough pose headless; polish is Guided)
Set `pose.bones[...].rotation_euler` directly in object mode (no mode switch); `view_layer.update()` to evaluate. **Probe an unknown axis** before committing: rotate +1.0 rad on each local axis, read a child bone's world pos.
```python
arm=next(o for o in bpy.data.objects if o.type=='ARMATURE'); pb=arm.pose.bones
for b in pb: b.rotation_mode='XYZ'; b.rotation_euler=(0,0,0)
def child_w(n): bpy.context.view_layer.update(); return arm.matrix_world @ pb[n].head
```
**Verified Synty-humanoid facts (this rig):** arm-DOWN (T-pose→hang) = **Z** on `Shoulder_*`; forward-hunch = **X** on the `Spine_*` chain + `Neck`/`Head`. Z is symmetric L/R; **X and Y MIRROR** — negate them for `*_L` (e.g. `Shoulder_L=(0, +y, -z)`, `Shoulder_R=(0, -y, -z)`). Hide the bone overlay for shots: `arm.hide_set(True)`.
## 8. The nudge-and-bake loop
**STAGE (I run):** mark + isolate + float + select + flat-shade + frame.
```python
mk=emissive("M_Marker",(1.0,0.0,0.85),4.0) # unmistakable magenta
head=arm.matrix_world @ pb["Head"].head
for nm,sx in (("SM_X_Eye_L",-0.05),("SM_X_Eye_R",0.05)):
e=bpy.data.objects[nm]
e.location=(head.x+sx, head.y-0.16, head.z+0.03) # float clearly IN FRONT of the face
e.data.materials.clear(); e.data.materials.append(mk)
for a in bpy.context.screen.areas:
if a.type=='VIEW_3D':
sp=a.spaces.active; sp.shading.type='MATERIAL'; sp.shading.use_scene_lights=False; sp.shading.use_scene_world=False
r=next(rg for rg in a.regions if rg.type=='WINDOW')
with bpy.context.temp_override(area=a,region=r): bpy.ops.view3d.view_axis(type='FRONT')
sp.region_3d.view_location=(head.x,head.y,head.z+0.03); sp.region_3d.view_distance=0.75
for ob in bpy.data.objects: ob.select_set(False)
eL=bpy.data.objects["SM_X_Eye_L"]; eR=bpy.data.objects["SM_X_Eye_R"]
eL.select_set(True); eR.select_set(True); bpy.context.view_layer.objects.active=eL
```
Then give the operator literal steps (select, `G`, orbit, "don't worry about depth"). Wait for "done".
**BAKE BACK (I run):** read placement → raycast depth-snap → restore material → parent to bone → reusable offset.
```python
from mathutils import Vector
dg=bpy.context.evaluated_depsgraph_get(); head=arm.matrix_world @ pb["Head"].head
GLO=emissive("M_Emissive_Gloam",(0.15,0.9,0.5),2.5)
hbmat=arm.matrix_world @ pb["Head"].matrix; hinv=hbmat.inverted()
for nm in ("SM_X_Eye_L","SM_X_Eye_R"):
e=bpy.data.objects[nm]; loc=e.matrix_world.translation.copy()
hit,hl,hn,idx,obj,mtx=bpy.context.scene.ray_cast(dg, Vector((loc.x,head.y-0.4,loc.z)), Vector((0,1,0)))
if hit: e.location=(loc.x, hl.y-0.010, loc.z) # snap depth to the face surface, keep operator X/Z
e.data.materials.clear(); e.data.materials.append(GLO)
for c in list(e.constraints):
if c.type=='CHILD_OF': e.constraints.remove(c)
c=e.constraints.new('CHILD_OF'); c.target=arm; c.subtarget="Head"; c.inverse_matrix=hinv # rides the rig
print(nm, "head-local offset:", tuple(round(v,3) for v in (hinv @ e.matrix_world.translation))) # REUSABLE
```
The printed **head-local offset** is the bake — record it (here + the asset's build step) so the next creature on this rig gets eyes auto-placed. Symmetrize L/R (±avg x, avg y/z) for the default. If a raycast misses (piece outside the mesh silhouette), keep the operator depth.
Alternatives: **empty-marker handshake** (operator drops empties named `eye.L`/`eye.R`; snap: `e.location = bpy.data.objects['eye.L'].location`). **Shrinkwrap** (`m=e.modifiers.new('sw','SHRINKWRAP'); m.target=body; m.wrap_method='PROJECT'`) to auto-conform to a surface — note it evaluates the target's geometry, verify on a posed/skinned mesh.
## 9. Verify — high-res render-to-file (beats the flaky viewport grab)
```python
import tempfile, os
sc=bpy.context.scene; sc.render.resolution_x=1000; sc.render.resolution_y=1000
for eng in ('BLENDER_EEVEE_NEXT','BLENDER_EEVEE'): # ⚠ on THIS 5.1 build the enum is 'BLENDER_EEVEE' (not _NEXT)
try: sc.render.engine=eng; break
except Exception: continue
sc.render.filepath = os.path.join(tempfile.gettempdir(), "artdev_preview.png") # OS temp — NEVER Library/Temp/Assets or any committed path
# ensure a camera exists + is active; frame it, then:
bpy.ops.render.render(write_still=True)
```
Then `Read` the PNG. The ~800px `get_viewport_screenshot` is a quick check but returns **BLACK/garbled when the window isn't drawing**`area.tag_redraw()` + `view3d.view_selected` then re-capture, or ask the operator to focus Blender. (Reason + more: [[mcp-screenshot-and-pose-validation]].)
### ★ Verify from the GAME CAMERA too, not just a hero 3/4
This is a **top-down ARPG** — an asset that reads in a hero shot can be invisible/wrong from the gameplay angle. **Always render from the game-camera angle** and judge there. Real rig values (`Assets/_Project/Scripts/Client/Presentation/PrototypeCameraRig.cs`): **Pitch 45° · Yaw 45° (default) · Distance 13 m · TargetHeight 1 m · FOV 55°**. Render at that *angle* but framed tighter (dist ~33.5) to see the asset, and check **both facings** — the camera sees the character's front when they move toward it and their **back when they move away** (so back-mounted kit reads too).
```python
import math; from mathutils import Vector
def make_cam(name,fov):
c=bpy.data.cameras.get(name) or bpy.data.cameras.new(name); c.lens_unit='FOV'; c.angle=math.radians(fov)
o=bpy.data.objects.get(name) or bpy.data.objects.new(name,c)
if o.name not in bpy.context.collection.objects: bpy.context.collection.objects.link(o)
o.data=c; return o
def aim(o,frm,tgt): o.location=Vector(frm); o.rotation_euler=(Vector(tgt)-Vector(frm)).normalized().to_track_quat('-Z','Y').to_euler()
def opos(tgt,pitch,yaw,dist): # game-cam geometry: pitch DOWN from horizontal, yaw around Z
t=Vector(tgt); p=math.radians(pitch); y=math.radians(yaw)
return t+(Vector((math.sin(y),-math.cos(y),0))*math.cos(p)+Vector((0,0,1))*math.sin(p))*dist
gf=make_cam("GameFront",55); aim(gf, opos((0,0,1.15),45,15,3.2),(0,0,1.15)) # facing toward
gr=make_cam("GameRear",55); aim(gr, opos((0,0,1.15),45,195,3.2),(0,0,1.15)) # facing away (back-kit)
# sc.camera = gf; render... then sc.camera = gr; render...
```
**The design consequence (learned on the Bathynaut):** from 45° top-down you see the **dome crown, shoulders, back-pack tops** — the **face/porthole is nearly invisible in-game.** Put detail + emissive accents on **top-facing surfaces** (helmet crown, shoulder lamp, tank tops); treat the face as a hero/close-up-only detail. Budget effort by where the camera actually looks.
## 10. Export + save
```python
import os; os.makedirs(SRC, exist_ok=True)
try: bpy.ops.file.pack_all() # self-contained .blend (a Synty .psd pack-warn is harmless)
except Exception as e: print("pack warn:", e)
bpy.ops.wm.save_as_mainfile(filepath=SRC + r"\<Type>_<Name>.blend")
# static export (glTF; metric scene = correct scale)
bpy.ops.export_scene.gltf(filepath=SRC + r"\SM_<Name>.glb", export_format='GLB',
use_selection=True, export_apply=True, export_yup=True)
```
Skinned/animation export → the per-action FBX recipe in [[blender-mcp-and-unity-mcp-v10]] (`bake_anim_use_all_bones`, `bake_anim_force_startend_keying`, `apply_scale_options='FBX_SCALE_UNITS'`, `add_leaf_bones=False`; Unity import `CreateFromThisModel`). **Never export into `Assets/` while a Unity session may be live** — write to `ArtSource/`, bake in later.
## 11. In-engine verification (Unity URP render harness) — the REAL A0 check
Blender previews are a proxy; the gate is the asset in **URP** under murk + bloom + the game camera. Import + render via UnityMCP (only when the editor is free / operator granted it — else stay read-only):
1. **Import** the glb: `import_model_file(source_path=<ArtSource glb>, output_folder="Assets/_Project/Art/Models", name=...)` — needs the leading `Assets/`; glTFast handles glb + scale.
2. **Confirm the shared material samples the atlas** — the palette ShaderGraphs expose it as **`_BaseColorMap`** (NOT `_BaseMap`/`mainTexture`, so `mat.mainTexture` reads null — that's fine): `ShaderUtil` enumerate or `mat.GetTexture("_BaseColorMap")`.
3. **Render harness** (`execute_code`, C#): load the `Mesh` from the imported glb, spawn a temp GO (mesh + shared material) on an **isolated layer**, warm-key + cold-fill directional lights (`cullingMask` = that layer), murk ambient (save+restore `RenderSettings.ambientMode/ambientLight`), a temp `Camera` (SolidColor murk bg, `cullingMask` = that layer, **FOV 55**) positioned at the **game angle** and framed by `MeshRenderer.bounds`, then **`RenderPipeline.SubmitRenderRequest(cam, new StandardRequest{destination=rt})`** (URP — `Camera.Render()` is unreliable) → `ReadPixels``EncodeToPNG` to OS temp → `Read` the PNG. `DestroyImmediate` all temp objects; never save the open scene.
-**Layer sign-bit:** `1<<31` is NEGATIVE → a broken culling mask that renders NOTHING (all-black). Use **layer ≤ 30**.
- ⚠ Set `cam.cullingMask` to ONLY the temp layer so the open scene's geometry doesn't leak into the shot (`~0` renders the whole scene).
- Game-cam offset from target: `(horiz*cos(pitch) + up*sin(pitch))*dist`, `horiz=(sin(yaw),0,-cos(yaw))`, pitch 45°, then `cam.transform.LookAt(center)`.
```csharp
// core render call (URP)
var rt=new UnityEngine.RenderTexture(1000,1000,24); rt.Create();
var req=new UnityEngine.Rendering.RenderPipeline.StandardRequest(); req.destination=rt;
UnityEngine.Rendering.RenderPipeline.SubmitRenderRequest(cam, req);
UnityEngine.RenderTexture.active=rt; var tex=new UnityEngine.Texture2D(1000,1000,UnityEngine.TextureFormat.RGBA32,false);
tex.ReadPixels(new UnityEngine.Rect(0,0,1000,1000),0,0); tex.Apply();
System.IO.File.WriteAllBytes(path, UnityEngine.ImageConversion.EncodeToPNG(tex));
```
**Findings from the first bake (crate):** the palette single-material pipeline works in URP; a pure-murk render reads *dark* (in-game URP bloom + scene fill lift it); grey under a warm key reads brownish (expected, not a bug). Skinned assets (suit/creatures) need the Rukhanka path + `Skinned-Palette` — hand to `/dots-dev`.
## 12. The persistent staging scene (`Assets/Scenes/ArtStaging.unity`) — the operator's in-engine view
A committed dev scene set up in the **correct A0 conditions** so the operator can OPEN it and inspect assets live (Scene-view orbit) at the gameplay angle: murk env (cold flat ambient + `ExponentialSquared` fog, no skybox), **warm-key + cold-gloam directional lights + a warm point pool**, a **Main Camera at the game angle** (pitch ~4245°, FOV 55, `UniversalAdditionalCameraData.renderPostProcessing=true`), and a **global Volume** (profile `Assets/_Project/Art/Materials/Staging/StagingVolume.asset`: **Bloom** thr~0.85/int~1.1 — makes Emissive-Gloam glow — + **Tonemapping ACES** + **ColorAdjustments** postExposure/saturation = the capture grade), on a dark faceted pedestal. Contains labeled `SLOT_*` empties for pending assets.
- **To verify a newly-baked asset:** `import_model_file` the glb → assign the shared material (`M_Lit_Palette` statics; emissive bulbs → an HDR-emission material so bloom triggers) → `PrefabUtility.InstantiatePrefab(asset, scene)` into ArtStaging at a slot → `SaveScene` → tell the operator to open it, OR render `Camera.main` via §11.
- ⚠ Opening ArtStaging `Single` closes the active scene — only do it when the editor is free / operator-authorized (a parallel agent's scene would be swapped out). Check `editor/state` first; the scene must be clean before swapping.
- Beginner view steps to hand over: open the scene (double-click in Project ▸ Assets/Scenes), **orbit** = middle-mouse drag, **zoom** = scroll, **frame a selected object** = `F`, **Game tab** shows the gameplay-angle camera.
### Underwater ambiance recipe (built into ArtStaging — the gameplay-env prototype)
The staging env IS the gameplay ambiance (build it from real systems so it transfers). Elements that made it read "underwater" (vs a bland dark disc):
- **Caustics** = a tileable cookie on an overhead **Spot** light (dappled seabed light). Generate procedurally (sum of `Sin` interference, `pow(1-abs(n), k)` for bright ridges), save PNG, importer `wrapMode=Repeat`, `light.cookie=tex`; spot ~4.5m up, angle ~85°, cool color, intensity ~40 (spots need high intensity). ⚠ a texture `SaveAndReimport` mid-render → one BLACK frame; just re-render.
- **Marine snow** = a `ParticleSystem` (real gameplay system): World sim space, ~500 particles, `startSpeed`~0.03, tiny size, cool-white low-alpha, box shape ~9×5×9, `noise` on for wander, slight negative gravity (drift up). Renderer material = `Sprites/Default` + a generated soft-dot texture. ⚠ edit-mode doesn't auto-play — `ps.Clear(); ps.Simulate(10f,true,true); ps.Pause();` **before** rendering.
- **Depth fog** teal (`ExponentialSquared`, cool color), **cool grade** (WhiteBalance temp ~-20, Vignette dark-teal), lifted cool **ambient** (Flat ~0.09,0.16,0.20), warm-key vs cold-fill contrast + a warm point "pool" (the beacon-in-murk look).
- **Seabed dressing** = scatter real meshes (my flora as bioluminescent ground-clutter = the readability-law density dial; Synty rocks as boulders). ⚠ **Synty rocks import HUGE** (DungeonRealms boulders are multi-metre) — never scatter at raw scale; **bounds-normalize**: instantiate at scale 1, measure `MeshRenderer.bounds` max dim, `scale = targetMetres / maxDim` (target ~0.351.0 m), then place. Strip colliders on cosmetic dressing.
### ★ LOCKED lighting = "light is territory" (the thematic template; reads TOP-DOWN)
The direction that landed (operator-approved): **the interest lives in the LIGHTING, not geometry.**
-**Vertical god-ray shafts DON'T read from a top-down camera** — you look straight down them. Tried, removed. For a top-down ARPG, thematic light must live in **pools ON the ground.**
- **Warm beacon pool** (a warm point light) = "our light / safe territory." **Cold bioluminescent pools** = a cold-teal point light AT each glow-flora (emissive meshes don't illuminate — add real lights). **Deep dark between** the pools (drop ambient to ~0.03,0.055,0.08, gentle key ~1.2 for form only) → chiaroscuro. This IS the "light is territory" pillar, made literal, and it reads perfectly top-down.
- **Undulating seabed mesh** (not a disc — a disc reads as a platform): procedural grid + layered `PerlinNoise` height, **damped to flat within ~2 m of the hero assets**, extended past the fog cutoff so the edge fades to murk. Flat-faceted (per-quad verts). Save as a `.asset` mesh so it persists.
- **Dynamic layer** = a Play-mode `MonoBehaviour` (`StagingAmbiance`) modulating **LIGHTS ONLY** (no material writes → nothing persists on Play exit): slow-spin the caustics spot, flicker the beacon, sine-pulse the flora pool intensities. Self-wires by `GameObject.Find`. ⚠ hitting Play in a non-menu scene runs `GameBootstrap` (spawns netcode worlds) — harmless to the visual, just background noise.
### Posed STATIC hero bake (style-proof placement without the Rukhanka pipeline)
To drop a posed suit/creature into the staging scene as a static mesh (skinned Rukhanka bake is separate `/dots-dev` work): in Blender — **apply the `Armature` modifier** on each skinned mesh (bakes the current pose into geometry), **`visual_transform_apply` + clear constraints** on bone-parented kit (eyes/lamp Child-Of), then export a static glb (meshes only, no armature). ⚠ both `modifier_apply` and `export_scene.gltf` need the **window `temp_override`** after `open_mainfile` (context.active_object). Import via `import_model_file`, `InstantiatePrefab` at the slot — glTFast carries the embedded materials (Synty atlas + emission).
```