Files
Project-M/.claude/skills/art-dev/references/blender-cookbook.md
T
kronic 749aedfbde Docs: Bathynaut helmet Synty-match + in-game body-swap session log + cookbook 14-E
Session log for the 07-24 character work. Adds cookbook 14-E: the per-instance _BaseColor override
gotcha (hit-flash sets URPMaterialPropertyBaseColor=(1,1,1) on player/enemy render children, replacing
the material _BaseColor -> flat null-map materials render cream; color via a solid _BaseColorMap texture).
Corrects the older 'clear the map -> uses _BaseColor' note.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 14:59:20 -07:00

340 lines
33 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`). **★ Blender 5.1: ALSO pass `bake_anim_use_nla_strips=False`** — slotted actions break the `use_all_actions=False` active-action path and the FBX exports with ZERO takes (silent, ~50KB vs ~270KB); with NLA strips off the exporter samples the evaluated scene over the frame range (set the scene range + `action_slot` per action first). Unity side: `ModelImporter.defaultClipAnimations` only enumerates takes AFTER a rig-typed `SaveAndReimport` (two-pass; see `PlayerRigTools.ImportUnderwaterClips`). **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).
```
## 13. Skinned attachment kit → existing Unity rig (proven 07-16, Bathynaut dome/tank/lamp)
Rigid accessories that must RIDE an already-in-engine Rukhanka rig (helmet, packs, lamps). Full failure-chain + Unity-side detail: gotchas archive 2026-07-16 + [[DR-052_SoD_Facing_Underwater_Feel]].
```python
# 1. BIND (non-destructive, saved into the master): per piece — one vgroup named for the target
# bone, ALL verts weight 1.0, + an Armature modifier. Pieces stay editable.
o.vertex_groups.new(name="Head").add(range(len(o.data.vertices)), 1.0, 'REPLACE')
o.modifiers.new("Armature", 'ARMATURE').object = ARM
# 2. JOIN copies per SHADER ROLE (palette brass vs emissive glow) -> 2 export meshes, vgroups merge by name.
# 3. EXPORT — ★ UNHIDE THE ARMATURE FIRST: a hidden armature can't be selected and the FBX
# exports SILENTLY SKINLESS (static meshes, no vgroups). Restore hidden after.
ARM.hide_set(False)
bpy.ops.export_scene.fbx(filepath=out, use_selection=True, object_types={'ARMATURE','MESH'},
apply_scale_options='FBX_SCALE_UNITS', apply_unit_scale=True, add_leaf_bones=False, bake_anim=False)
```
Unity side (`PlayerRigTools.AttachBathynautKit` / `GraftSmr` is the reference implementation):
- Rebind `smr.bones` by NAME onto the target skeleton (Blender dedup suffixes `.001` → strip to base name; safe when the kit only weights unambiguous bones).
- **REBASE, never reuse bindposes**: a Blender FBX roundtrip imports cm bones under a 0.01 armature (regardless of scale option) while Synty-native rigs are meter-scale → raw bindpose reuse renders ×100 off. Bake verts to rest-world; bindposes = `Matrix4x4.TRS(m.GetColumn(3), m.rotation, Vector3.one).inverse` (RIGID, scale-stripped).
- **`mesh.RecalculateTangents()` is mandatory** — a tangent-less procedural skinned mesh fails Rukhanka/BRG registration (`BatchMeshID not present`) and the WHOLE rig disappears.
- Persist rebased meshes as `Rebased_*.asset` (Clear+refill an existing asset = GUID-stable re-runs).
- Emissive pieces: `ProjectM/EmissiveGloamSkinned` (hand-written HLSL + Rukhanka `ComputeDeformedVertex`; the DOTS-instanced `_DeformedMeshIndex` block must be declared BEFORE the include, and the property must ALSO be in the Properties block for the baker's `HasProperty` validation). BRG-only: invisible in plain classic scenes, correct in the baked ECS world.
## 14. AI-generated hero shape → conform to Synty (proven 07-24, Bathynaut Mark-V helmet)
When scripted `bmesh` primitives can't nail an **iconic organic hard-surface shape** (a classic diving helmet, an ornate boss horn) — repeated hand attempts read as "golf-ball / egg / blocky robot" — **generate the base shape with Hyper3D Rodin** (`generate_hyper3d_model_via_text` → poll → `import_generated_asset`), then **conform it to the LANTERN/Synty style**. Generation is a valid *modeling* path (like a kitbash); the output is raw clay, NOT a finished asset — it arrives photoreal, high-poly, meter-scale, single-textured, and **clashes hard** dropped next to the flat-shaded low-poly body. The conform pass is the real work:
**A. Watertight decimate — clean topology FIRST, then a GENTLE collapse.** A raw generated mesh has duplicate verts + loose geometry; an aggressive collapse (ratio ~0.10) on it **tears holes and shatters facets** (the operator will see it). Order matters:
```python
import bmesh
bm=bmesh.new(); bm.from_mesh(h.data)
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=0.0006) # weld
bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
loose=[v for v in bm.verts if not v.link_faces]
if loose: bmesh.ops.delete(bm, geom=loose, context='VERTS')
bm.to_mesh(h.data); bm.free()
d=h.modifiers.new("Dec","DECIMATE"); d.decimate_type='COLLAPSE'; d.ratio=0.26 # ~1k tris for a hero prop
dg=bpy.context.evaluated_depsgraph_get(); h.data=bpy.data.meshes.new_from_object(h.evaluated_get(dg)); h.modifiers.clear()
# HOLE DETECTOR: boundary edges (edges touching <2 faces). MUST be 0 = watertight.
bm=bmesh.new(); bm.from_mesh(h.data); holes=sum(1 for e in bm.edges if len(e.link_faces)<2); bm.free()
```
23k→1.1k with 0 boundary edges reads as chunky Synty facets. If `holes>0`, the collapse was too aggressive for the cleanup — raise the ratio (gentler). Report tris vs budget.
**B. Kill the photoreal texture — flat palette color is the biggest tell.** The single baked albedo/PBR map (painted weathering, smooth metal highlights) is what makes it look "AI dropped on low-poly." **Discard the imported material** and re-paint the mesh with the scene's **OWN shared palette materials** (assign by face region → guaranteed cohesion, literally the same material asset). Set **flat/faceted shading** (`for p in mesh.polygons: p.use_smooth=False`) so each plane reads as a facet. Region-assign by world-space geometry on the *decimated* mesh (fewer faces = cleaner colour blocks):
```python
h.data.materials.clear()
for m in (brass, gunmetal, glass): h.data.materials.append(m) # reuse the suit/scene's flat mats
mw=h.matrix_world; bm=bmesh.new(); bm.from_mesh(h.data); bm.faces.ensure_lookup_table(); bm.verts.ensure_lookup_table()
zs=[(mw@v.co).z for v in bm.verts]; zmin,zmax=min(zs),max(zs); zh=zmax-zmin
port=mathutils.Vector((0,-0.15,1.68)) # feature centre in WORLD space
for f in bm.faces:
c=mw@f.calc_center_median(); n=(mw.to_3x3()@f.normal).normalized(); vz=[(mw@v.co).z for v in f.verts]
s=0 # brass body (dominant)
if min(vz)>zmax-zh*0.085 or max(vz)<zmin+zh*0.11: s=1 # top knob / bottom collar = accent (all-verts test = clean band)
d=(c-port).length
if n.y<-0.25 and d<0.10: s=2 if d<0.072 else 1 # front-facing near feature: inner disc glass, ring bezel
f.material_index=s; f.smooth=False
bm.to_mesh(h.data); bm.free()
```
Tie any emissive to the **ontology** (the helmet porthole = teal glass, faint glow ~0.5 → reads as the same bioluminescent palette as the flora/enemy-eyes, not a competing warm light — the shoulder lamp carries "our light"). Use `min/max(vert-z)` (all verts in/out) for clean bands, not face-centre thresholds (torn edges on a dense mesh).
**C. Scale gotcha — generated meshes are METER-scale; a cm-scale rig shrinks a skinned attachment ~100×.** Rodin exports ~12 m meshes; the Synty suit rig is cm-scale (0.01 armature). Skinning the generated piece to a bone applies the armature's 0.01 → it imports at **~5 mm (invisible)** with a wrong bindpose. For a rigid accessory (helmet, pack) **do NOT skin it** — drop the armature modifier + vgroups, `parent=None`, and set a plain `scale` so it sits at the feature's world size (a 1.7-unit generated mesh × 0.25 = ~0.42 m = head-sized). It rides the bone rigidly in-engine (Unity-side: parent the GO to the `Head` bone, §13 covers the skinned path when you DO want deform).
**D. Judge cohesion in-engine, at game scale, next to the existing Synty assets.** A generated mesh that looks fine solo can still clash beside the low-poly body — the only true test is a Play capture with the character next to its neighbours (the suit, an enemy) at the real game framing (§9). The Blender render confirms the mesh is clean; the *style match* is an in-engine, next-to-siblings call.
Recipe verified on the Mark-V: 4 hand-model attempts failed → Rodin generate → clean-decimate 23k→1.1k (0 holes) → drop the copper photoreal map → flat `M_Diver_Brass`/`HelmetMetal` + a teal `M_Diver_Porthole` + flat-shade → reads as one cohesive Synty diver (operator-approved). GLB 1.7 MB → 736 KB (texture dropped).
**E. ★ Coloring on the GAMEPLAY (deformation) rig ≠ staging — flat `_BaseColor` FAILS.** A static staging mesh takes flat colour from the material `_BaseColor` (plain Principled / the `Skinned-Palette` graph). But the animated player/enemy render entities carry a **per-instance `URPMaterialPropertyBaseColor` override** (driven by the hit-flash system, set to `(1,1,1)` at rest) which **REPLACES** the material's `_BaseColor` — so a flat-`_BaseColor` material with `_BaseColorMap = null` renders **white/cream** (null map samples white × instance-white). (This corrects the older "clear `_BaseColorMap` → uses `_BaseColor` directly" note — that only holds for entities WITHOUT the per-instance override.) Fix: drive colour from `_BaseColorMap` — either UV the piece to a `PaletteAtlas` band, or assign a **solid-colour texture** (an 8×8 PNG of the target colour) as `_BaseColorMap` and leave `_BaseColor` white; the instance override then just multiplies (and the flash still tints correctly). Diagnose by reading the live entity: `EntityManager.HasComponent<URPMaterialPropertyBaseColor>` on the `LinkedEntityGroup` render children → if present and `(1,1,1)`, the material `_BaseColor` is dead, use a map. Per-submesh colour (e.g. the Mark-V's brass/gunmetal/glass) = one material per submesh, each with its own solid `_BaseColorMap`. Verified live: `M_Diver_*_Skinned` + `DiverTex/T_Diver_*.png` on `Player.prefab`.