Docs: /art-dev skill + art-track kickoff session log
New /art-dev skill (SKILL.md + blender-cookbook + style-and-practices refs) driving LANTERN Blender art work: Auto (headless) for simple changes, the hybrid nudge-and-bake loop for precision/aesthetics, game-camera verification, Blender-beginner coaching. Session log covers the A0 style-proof asset set + the Bathynaut refinement. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
# 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`). **Never export into `Assets/` while a Unity session may be live** — write to `ArtSource/`, bake in later.
|
||||
```
|
||||
@@ -0,0 +1,57 @@
|
||||
# Style essentials + Blender best-practices (LANTERN art-dev)
|
||||
|
||||
The **binding** style lives in [[Art_Direction_Lantern]] (constitution) + [[Lantern_Art_Pipeline_Recipes]] (manual). This file is the *operative subset* to hold in-head during a session, plus the **Blender-beginner** coaching the operator needs. When this and the art docs disagree, the docs win.
|
||||
|
||||
## The five rules that shape every asset
|
||||
|
||||
1. **One material, color by UV placement.** Never texture/unwrap per asset. Model → *place* each face's UV onto a palette-atlas colour band (`Closest`-filtered) → flat colour. Three shader roles only: **Lit-Palette** (statics), **Emissive-Gloam** (glows), **Skinned-Palette** (creatures/suits, deformation-aware). Batching + coherence + zero texture skill = the solo-dev factory.
|
||||
2. **Faceted low-poly, silhouette-first.** Flat shading (`face.smooth=False`). The read is the *shape* + *light*, not detail — "a readable blob with a great light beats a detailed model nobody can see."
|
||||
3. **Budgets (hard):** props ≤ ~800 tris · creatures ≤ ~3k · hero/boss/suit-frame ≤ ~6k. Report tris every time; over-budget → decimate or drop parts (e.g. body OR armour, not both).
|
||||
4. **The palette IS the ontology:** warm gold/amber = **true light (ours)** · cold corpse-green/blue = **gloam (the deep's counterfeit)** · lure-red = **bait** · white-hot = **conversion**. Pick an asset's emissive hue for what it *is* (diver beacon = warm; wisp/gloam-enemy = cold green; ambient flora = cold teal; lure = red).
|
||||
5. **The readability law:** value-first (carry every warm/cold read by luminance + shape + **flicker-cadence**, not hue alone — steady = true, flicker = false; this is the colorblind answer). Reads at **200 px**. A **capture grade** (lifted/saturated) for shots; the murk grade is darker.
|
||||
|
||||
## Rig families (skinned assets — clips are made per FAMILY, not per creature)
|
||||
|
||||
| Family | Body plan | Members | Base to kitbash |
|
||||
|---|---|---|---|
|
||||
| **Drifter** | spine-chain swimmer | Wisp-Choir motes, lure-fish, serpents | procedural blobs + tail beads (no humanoid rig) |
|
||||
| **Crawler** | legged/scuttling | Grindylow, kappa-kin, Tenders | (quadruped — separate rig) |
|
||||
| **Strider** | biped-ish | Drowner, Wrecker, **suit-frames** | Synty humanoid (`SM_Chr_*` in the combined FBX) |
|
||||
|
||||
Suit-frames = kitbash on the Synty humanoid (helmet/tank/plate swaps over the proven rig); a custom silhouette sculpt is a later upgrade, not a blocker.
|
||||
|
||||
## Assets built so far (in `<repo>/ArtSource/Blender/`, the style-proof set)
|
||||
|
||||
`Prop_CargoCrate` (92 tris, statics/palette proof) · `Prop_GlowFlora` (380, teal ambient) · `Creature_WispMote` (110, gloam-green Drifter) · `Creature_Drowner` (2742, Synty-crew Strider kitbash — gloam skin, hunched, bone-parented glowing eyes) · `Suit_Bathynaut_Kitbash` (8.4k ⚠ over hero budget — body+armour+kit; game-ready pass must pick body-or-armour + decimate). Concepts: suit kit + wisp not rig-bound; drowner posed not animated. Pending: the in-engine Unity bake + the 3 master ShaderGraphs (esp. Emissive-Gloam's flicker param).
|
||||
|
||||
## Coaching a Blender beginner (the operator)
|
||||
|
||||
On **every** handoff, give a short **numbered, literal** step list — only the keys they need for that step — and one line on *why* (the best practice). The vocabulary:
|
||||
|
||||
| Do this | How (tell them exactly) |
|
||||
|---|---|
|
||||
| Orbit the view | **middle-mouse drag** (or numpad `4/6/8/2`) |
|
||||
| Zoom | **scroll** (or `Ctrl`+MMB drag) |
|
||||
| Pan | **Shift**+MMB drag |
|
||||
| Frame selected | **`Numpad .`** (or View ▸ Frame Selected) |
|
||||
| Front / side / top view | **`Numpad 1` / `3` / `7`** |
|
||||
| Select an object | **left-click** it; **Shift**+click to add to selection |
|
||||
| Move (grab) | **`G`**, move mouse, **left-click** to confirm, **`Esc`/right-click** to cancel |
|
||||
| Move on one axis | **`G` then `X`/`Y`/`Z`** (e.g. `G Z` = up/down only) |
|
||||
| Scale / Rotate | **`S`** / **`R`** (same confirm/cancel; add an axis letter to constrain) |
|
||||
| Save | **`Ctrl S`** |
|
||||
|
||||
- Hand the operator only **intuitive direct-manipulation** (grab/scale/rotate a selected object onto a target). Keep the fiddly/risky ops (mode switches, modifiers, bmesh, export settings, materials) **headless** on my side.
|
||||
- When one axis is hard to see in a single view (depth), tell them to ignore it — "place it in this front view, I'll snap the depth after" (raycast/shrinkwrap).
|
||||
- Explain the *why* briefly so they learn: flat shading = the low-poly look; UVs onto a band (not unwrapped) = the shared-material rule; apply transforms before export = correct scale in Unity; under the tri budget = it stays cheap in the dark.
|
||||
- If a request would break a rule (e.g. "paint a texture", "make it super smooth/high-poly"), say so plainly and offer the palette-correct / budget-correct way.
|
||||
|
||||
## Best-practice do / don't
|
||||
|
||||
**Do:** faceted flat shading · UVs placed on atlas cells · metric units, 1 u = 1 m · feet at z=0, origin sensible · apply rotation/scale before export · pack textures before saving the `.blend` · one shared material per asset · report tris vs budget · verify with a hi-res render under murk, judging silhouette + value + 200px · **also render from the game camera (top-down ARPG, Pitch 45°/FOV 55°), both facings — put detail + emissive accents on TOP-facing surfaces (crown/shoulders/back-pack), not the face the camera never sees** · keep each asset a separate master `.blend` under `ArtSource/Blender/`.
|
||||
|
||||
**Don't:** per-asset texture/unwrap · smooth-shade the low-poly forms · exceed the tri budget silently (log the upgrade if you ship a placeholder) · overwrite a Synty pack FBX (GUID refs — new files only) · write into `Assets/` while a Unity session may be live · trust a dark screenshot (check material *values*) · trust `WorldRenderBounds`/bone reads for a *skinned/posed* mesh (they return the bind AABB — use the operator's eyes) · leave emission at strength 4–6 on AgX (whites out — Standard + saturated color, ~1.5–3).
|
||||
|
||||
## The pipeline into Unity (hand off to /dots-dev)
|
||||
|
||||
Statics: model → export `.glb` → collider policy (cosmetic = strip colliders/no ghost; blocking = `Environment` collider baked into the gameplay subscene, mesh Read/Write on) → assign the shared `M_Lit_Palette` → bake into subscene → verify baked CollisionWorld + a Play screenshot. Skinned: Humanoid rig → per-action clips (Key All Bones, bake-to-pose) → FBX → Rukhanka ingest (`CreateFromThisModel`) → `Skinned-Palette` → prefab via `EnemyRigTools` (ownerless interpolated ghost; server strips Rukhanka). Full checklists + gotchas → [[Lantern_Art_Pipeline_Recipes]] + `CLAUDE.md` (Animation / Art-import sections).
|
||||
Reference in New Issue
Block a user