PizzaPocket

object-photography

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.

PizzaPocket 0 Updated 2w ago
GitHub

Install

npx skillscat add pizzapocket/eleblorb/object-photography

Install via the SkillsCat registry.

SKILL.md

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 SubViewport inherits the parent viewport's World3D by default unless own_world_3d = true is 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_mode changes (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. Two await tree.process_frame cycles 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 DirectionalLight3D aimed by hand-composed rotation_degrees is 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 put target at 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():

  1. Build the SubViewport with size, transparent_bg = true (if the UI needs to composite over its own background), and own_world_3d = true.
  2. 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. A SubViewport only renders its own descendants.
  3. 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 a portrait_mode-style flag (see blorb.gd) that skips that gameplay hookup while still building the visual — and check every @onready var on 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-in Node with the right name is enough to satisfy one if the object never actually queries it in that mode.
  4. Add the SubViewport itself to tree.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 in player_portrait.gd). Node3D.global_transform resolves by walking the parent chain regardless of SceneTree membership, 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 to tree.root once, 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.
  5. Measure the object's actual rendered bounds off its MeshInstance3D descendants specifically, not the broader VisualInstance3D (see _world_aabb() in blorb_portrait.gd/player_portrait.gd: find_children("*", "MeshInstance3D", true, false) — owned=false is required, since code-built meshes have no scene owner set and the default owned=true would silently match nothing — and merge each one's global_transform * get_aabb()). VisualInstance3D looks like the more obviously-correct filter (it's the actual base class get_aabb() is defined on), but in Godot 4 Light3D is also a VisualInstance3D subclass — a subject that carries its own light (blorb.gd's fire/electric core OmniLight3D, _add_core_light()) gets that light's get_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.
  6. 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.
  7. Place the camera and lights via look_at(target, up), not hand-derived rotation_degrees — position anywhere convenient (a DirectionalLight3D's position doesn't affect the light itself, only its look_at-derived rotation does) and let look_at() compute the correct orientation. Add one WorldEnvironment with ambient_light_source = AMBIENT_SOURCE_COLOR and a modest ambient_light_energy as a floor — cheap insurance against a mis-aimed light leaving the subject unlit. Set camera.current = true explicitly rather than relying on auto-assignment.
  8. Wait two await tree.process_frame cycles, then viewport.get_texture().get_image() → ImageTexture.create_from_image().
  9. 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_angle is half of Camera3D's own vertical FOV (75° default → ~37.5°). Bias margin upward, 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 off bounds.size.x — and take whichever needs more distance; that's the axis actually constraining whether the subject fits in frame. Use a margin > 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) guarantees target lands 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 as target (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 target before calling look_at() (camera.position = target + Vector3(0, y_offset, 1.0) * distance, y_offset != 0) tilts the whole frustum, not just the camera's position — target still 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.gd cropped a figure's head at y_offset = 0 just 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 of y_offset shifts 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):

  1. 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).
  2. 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 θ.
  3. 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)), where local_Z is the camera's own +Z (pointing away from its target, i.e. camera.position - target, normalized).
  4. 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 = true on the SubViewport
  • Subject (and any stand-in sibling nodes it needs) parented inside the SubViewport, not alongside it
  • SubViewport added to tree.root before anything measures/reads its descendants
  • Camera framed off a measured MeshInstance3D AABB (not the broader VisualInstance3D — 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-composed rotation_degrees
  • A WorldEnvironment ambient-light floor present
  • camera.current = true set explicitly
  • Two process_frame awaits 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

Categories