Files
Project-M/.claude/skills/art-dev/references/blender-cookbook.md
T
kronic c78768cbdf Docs: art-dev skill - AI-generate->conform-to-Synty recipe (cookbook 14)
Bank the Mark-V lessons: when scripted primitives can't nail an iconic organic shape, generate with
Hyper3D Rodin then CONFORM - watertight clean-decimate (weld+recalc first, gentle collapse, 0 boundary
edges = hole detector), drop the baked photoreal texture for the shared flat palette, flat-shade,
ontology-tie emissives, and don't skin a meter-scale generated mesh onto a cm-scale rig (~100x shrink).
Added the generate path to the Build phase + a keystone clause (strip a kitbash/gen mesh's own texture).
Also fixed a duplicate section number (12 -> 13).

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

31 KiB
Raw Blame History

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):

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)

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)

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:

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

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):

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

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.

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.

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.

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)

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 drawingarea.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).

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

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) → ReadPixelsEncodeToPNG 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).
// 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 thr0.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:

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):

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).