749aedfbde
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>
340 lines
33 KiB
Markdown
340 lines
33 KiB
Markdown
# 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 (~30–80) + 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 4–6 + AgX** clips to white and desaturates. On `Standard`, keep strength ~1.5–3 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)** |
|
||
| 0–1 | 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 ~3–3.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 ~42–45°, 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.35–1.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 ~1–2 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`.
|