Rendering a live 3D object (or any Godot scene subtree) to a still 2D texture for UI display via a SubViewport "photoshoot" rig — the technique behind blorb_portrait.gd's Blorbs-tab portraits. Use whenever building or debugging an in-engine object-to-texture capture: framing/centering a subject, posing it, lighting it, or diagnosing a capture that comes out blank, cropped, zoomed wrong, or showing the wrong scene.
Install
npx skillscat add pizzapocket/eleblorb/object-photography Install via the SkillsCat registry.
Photographing a 3D object for UI (SubViewport capture)
Turning a live Godot scene object into a flat texture for a UI panel (an inventory portrait, an icon, a preview thumbnail) means building a small, temporary "photo studio" — a SubViewport with its own camera, lights, and a copy of the object — capturing one frame, and reading it back as an ImageTexture. scripts/blorb_portrait.gd is the reference implementation (built for InventoryUI's Blorbs tab); read it before starting a new one, since the working recipe below is what that file settled on after several rounds of getting it wrong.
Why this is harder than it looks
Every step below broke at least once during blorb_portrait.gd's own development, each time for a specific, mechanical reason — not from anything hard to reason about once you know to look for it:
- Measuring the object before it's actually built.
add_child()ing a node onto another node that is itself not yet in the scene tree does not run that node's_ready(). Godot only calls_ready()once a node enters a tree that's already live. Building the whole rig off-tree and then measuring/framing off a descendant before adding the rig itself to the tree measures an object with no meshes yet — an empty, zero-size bounding box. The distance math built on top of that came out to ~0, putting the camera inside the mesh (rendered as "a full window of skin," not a photo of anything). - Sharing the wrong 3D world. A
SubViewportinherits the parent viewport'sWorld3Dby default unlessown_world_3d = trueis set — without it, a capture rig added anywhere under the running game's own tree renders the entire live scene behind the subject, not an isolated shot. - Reading back before the GPU has actually rendered.
render_target_update_modechanges (and anything newly parented into the viewport) only take effect starting the next frame.get_texture().get_image()called the same frame it was set up reads a stale/blank buffer. Twoawait tree.process_framecycles after the rig enters the tree is the margin blorb_portrait.gd settled on. - Trusting assumed dimensions over measured ones. Framing a camera off constants you believe describe the object's size (even ones confirmed correct elsewhere, like gameplay-verified
BODY_HEIGHT/RADIUS) is still a guess about how those numbers combine with camera FOV/distance math, and it was wrong twice before switching to measuring the object's actual rendered AABB directly (see below). - Assuming a light lands on the visible face. A
DirectionalLight3Daimed by hand-composedrotation_degreesis easy to get backwards — no visual preview to check it against. If a light effectively backlights a translucent/rim-lit material, the front the camera sees can read as almost invisible against a transparent background, producing a shape that looks "cropped" even though it's really just unlit. - A camera-angle change that shouldn't move the subject, but does.
camera.look_at(target, up)is supposed to puttargetat the exact center of frame regardless of where the camera sits — so swinging the camera's own yaw to get an angled ("three-quarter") view should never shift which side of frame the subject sits on. It did anyway, for reasons never fully root-caused. The fix that actually worked: stop moving the camera for posing — rotate the object around its own fixed pivot instead, and leave the camera at the one position already confirmed to center correctly (see "Posing" below). This sidesteps whatever was wrong rather than explaining it, which is a legitimate way to close out a bug you can't fully diagnose without a live preview — but flag that trade explicitly in the code, the way blorb_portrait.gd's own comment does, so the next person knows a real question was left open rather than assuming it was chased down.
The working recipe
In order, matching blorb_portrait.gd's own _capture():
- Build the
SubViewportwithsize,transparent_bg = true(if the UI needs to composite over its own background), andown_world_3d = true. - Parent everything the render needs — the subject, any stand-in nodes it requires, the camera, the lights — directly into the
SubViewport, not some other wrapper node added alongside it. ASubViewportonly renders its own descendants. - Instantiate the real object from its actual scene/script, not a hand-rebuilt approximation of its geometry — reuse the exact mesh-building code the live gameplay object uses, so the photo matches what the object actually looks like in-game and stays in sync automatically as that code changes. If the object's own
_ready()assumes siblings that only exist in real gameplay (a Player, a Terrain, ...) that don't exist in the capture rig, give it aportrait_mode-style flag (see blorb.gd) that skips that gameplay hookup while still building the visual — and check every@onready varon the object too, not just its_ready()body: those resolve unconditionally on tree-entry and can't be guarded by a flag checked later. A bare stand-inNodewith the right name is enough to satisfy one if the object never actually queries it in that mode. - Add the
SubViewportitself totree.root— this is the point every descendant's_ready()actually runs. Do this before step 5, not after — unless the subject's construction is purely imperative (built by calling static functions directly, no scene/script_ready()involved anywhere in the rig, as inplayer_portrait.gd).Node3D.global_transformresolves by walking the parent chain regardless ofSceneTreemembership, so in that specific case it's fine to build the whole rig fully off-tree — body, worn/attached pieces, camera, lights — and attach it totree.rootonce, right before the frame-wait in step 8. Don't generalize that shortcut back to a subject whose own geometry comes from a script's_ready()(blorb_portrait.gd's own case) — there, step 4 before step 5 is still required. - Measure the object's actual rendered bounds off its
MeshInstance3Ddescendants specifically, not the broaderVisualInstance3D(see_world_aabb()in blorb_portrait.gd/player_portrait.gd:find_children("*", "MeshInstance3D", true, false)—owned=falseis required, since code-built meshes have no sceneownerset and the defaultowned=truewould silently match nothing — and merge each one'sglobal_transform * get_aabb()).VisualInstance3Dlooks like the more obviously-correct filter (it's the actual base classget_aabb()is defined on), but in Godot 4Light3Dis also aVisualInstance3Dsubclass — a subject that carries its own light (blorb.gd's fire/electric coreOmniLight3D,_add_core_light()) gets that light'sget_aabb()(sized to its influence radius, e.g.omni_range=2.2— nothing to do with any actual geometry) merged straight into the bounds. That single light can dwarf the real mesh by an order of magnitude, computing a camera distance far past what the subject needs and rendering it consistently tiny/distant compared to otherwise-identical subjects without a light — confirmed as the actual cause of exactly that symptom (an Electric blorb's portrait reading smaller/farther-away than a Normal one), and notably a different bug from, and not fixed by, cache staleness (see below) — a fresh capture reproduces it every time until the filter itself is narrowed. Frame the camera off this measured box (center → look-at target; size → camera distance), not off assumed constants. - Sanity-check the measurement before trusting it. A near-zero-size result almost always means step 4 hasn't actually happened yet (see the first bullet above) rather than a genuinely tiny object — fall back to a known-reasonable framing instead of computing a camera distance from it blindly.
- Place the camera and lights via
look_at(target, up), not hand-derivedrotation_degrees— position anywhere convenient (aDirectionalLight3D's position doesn't affect the light itself, only itslook_at-derived rotation does) and letlook_at()compute the correct orientation. Add oneWorldEnvironmentwithambient_light_source = AMBIENT_SOURCE_COLORand a modestambient_light_energyas a floor — cheap insurance against a mis-aimed light leaving the subject unlit. Setcamera.current = trueexplicitly rather than relying on auto-assignment. - Wait two
await tree.process_framecycles, thenviewport.get_texture().get_image()→ImageTexture.create_from_image(). - Free the rig (
viewport.queue_free()— frees every descendant with it) and cache the resulting texture keyed by whatever distinguishes one "look" from another, so repeat requests for the same appearance don't re-shoot it.
Because step 8 is inherently a couple of frames away, the whole thing is async by construction — see blorb_portrait.gd's apply_portrait()/_apply_async() for the fire-and-forget pattern (show a placeholder immediately, swap in the real texture once the capture resolves, guard with is_instance_valid() in case the UI element it was headed for got freed in the meantime) and its _pending dict for de-duplicating simultaneous requests for the same not-yet-cached look.
Framing math
- Camera distance from a measured AABB:
half_extent = bounds.size.length() * 0.5,distance = (half_extent / tan(fov_half_angle)) * margin.fov_half_angleis half ofCamera3D's own vertical FOV (75° default → ~37.5°). Biasmarginupward, not down, when unsure — a subject that renders a bit small in frame is a minor cosmetic issue; a cropped one reads as broken. - The diagonal-length shortcut above only holds up for a roughly blob-shaped subject (a blorb, similar extent on every axis). For anything visibly elongated on one axis (a standing human figure — tall, comparatively narrow, see
player_portrait.gd), the diagonal blends height and width into one number that can under-frame the dominant axis entirely, producing a full-frame close-up of whatever part happens to sit at the look-at target rather than the subject's actual full extent. Fit height and width separately instead —distance_for_height = (bounds.size.y * 0.5) / tan(fov_half_angle), same formula for width offbounds.size.x— and take whichever needs more distance; that's the axis actually constraining whether the subject fits in frame. Use amargin> 1.0 (real padding) rather than < 1.0 for this kind of reference/full-body view, where a bit of headroom outside the subject is worth more than risking this exact crop. camera.look_at(target, up)guaranteestargetlands at the exact center of the rendered frame, for any camera position/distance. Treat this as a hard mathematical fact, not a rule of thumb: if a capture comes out off-center, the bug is in what you measured astarget(or when you measured it — see the "measuring before built" gotcha), never in the distance or angle math layered on top of it.- A camera offset vertically from
targetbefore callinglook_at()(camera.position = target + Vector3(0, y_offset, 1.0) * distance,y_offset != 0) tilts the whole frustum, not just the camera's position —targetstill lands dead-center (see above), but the frustum's slack no longer splits evenly above/below it the way a level (y_offset = 0) camera's does. This matters for anything taller than it is wide (a standing figure, not a blob): get the sign wrong and one end (the head, say) crops while the other (empty floor below the feet) gets slack it didn't need. Two things this project's own trial-and-error surfaced, worth carrying into a new rig: (1) level isn't automatically the safe default —player_portrait.gdcropped a figure's head aty_offset = 0just as it had at a nonzero offset, so a level camera doesn't guarantee the frustum's true vertical center lines up with the subject's regardless of intent; (2) which literal sign ofy_offsetshifts slack toward vs. away from the cropped end isn't something to re-derive confidently from first principles and trust — it took two rounds of direct correction to land on the right one here. Change it, look at a real render, and confirm which end gained room before treating either the value or its sign as settled for a new rig.
Posing (turning the subject to face a direction)
Don't move the camera to get an angled/three-quarter view — rotate the object, before measuring its AABB (step 5), and leave the camera at whatever position is already confirmed to frame correctly. This isn't just stylistic caution left over from one bug: rotating the subject around its own fixed pivot cannot move its own measured center away from wherever the camera is already aimed, so it can't reintroduce a centering bug the way moving the camera already once did here.
To work out which rotation sign turns the subject to face a specific screen direction (not just "clockwise/counterclockwise" — actually correct on screen):
- Identify the object's own local "facing" axis from its geometry (e.g. blorb.gd's face is centered on local +Z — see blorb_face.gd's eye placement).
- Apply Godot's actual Y-rotation formula (see the figure-rig skill's own copy of this:
x' = cos(θ)x + sin(θ)z,z' = -sin(θ)x + cos(θ)z) to that local facing vector to get its world direction after rotating the object byθ. - Separately derive the camera's own "screen right" direction at its fixed position — don't assume it's world +X or -X. Work it out from
look_at()'s actual basis construction:right = normalize(cross(up, local_Z)), wherelocal_Zis the camera's own +Z (pointing away from its target, i.e.camera.position - target, normalized). - Match the signs from steps 2 and 3 against whichever screen direction you actually want the subject to face.
This is the same category of problem the figure-rig skill covers for the player/NPC rig's joints (guessing an axis/rotation sign wrong) — cross-reference that skill's own transform-math method rather than re-deriving the Y-rotation formula from scratch each time.
Caching survives longer than you'd expect during iteration
A static var cache (blorb_portrait.gd's _cache) persists for the life of the running game process, not just one script version — and Godot's own dev loop for this project is to keep the game open while scripts keep changing underneath it (a hot-reload, not a restart). That means a texture captured once under an old version of the framing/lighting math stays cached and gets served forever after, even once the capture code has been tuned and would now produce something different — nothing about editing the script invalidates what's already in the dictionary. This produced a real, confusing symptom: after a camera-distance fix, freshly-captured subjects looked correctly framed while one captured earlier (and never re-requested since) still showed the old, wrong framing right next to them — read as a new, inconsistent bug when it was really just staleness.
Fold a version number into the cache key (blorb_portrait.gd's CAPTURE_VERSION) and bump it whenever the rig itself changes (camera distance/angle, pose, lighting) — not when the subject changes, which the key already accounts for separately. This makes a tuning pass self-invalidating instead of needing a full game restart (or, worse, going undiagnosed as a caching issue at all) to see its own effect consistently.
Checklist for a new capture rig
-
own_world_3d = trueon theSubViewport - Subject (and any stand-in sibling nodes it needs) parented inside the
SubViewport, not alongside it -
SubViewportadded totree.rootbefore anything measures/reads its descendants - Camera framed off a measured
MeshInstance3DAABB (not the broaderVisualInstance3D— see step 5, a subject's own light gets caught by that filter too), with a sanity fallback for a near-zero result - Lights (and camera) aimed via
look_at(), not hand-composedrotation_degrees - A
WorldEnvironmentambient-light floor present -
camera.current = trueset explicitly - Two
process_frameawaits before reading back the texture - Rig freed and result cached after capture, with a version number in the cache key that gets bumped whenever the rig itself changes
- Any pose/angle achieved by rotating the subject, not the camera