81f7db0cfa
Adds the in-engine verification recipe (URP SubmitRenderRequest render harness, layer sign-bit gotcha, game-camera check) and the persistent ArtStaging scene recipe. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
253 lines
20 KiB
Markdown
253 lines
20 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`). **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.
|
||
```
|