Reference14 min read10 sections

Recursive portal rendering

docs/VISIBLE_PORTALS.mdSource updated 2026-10-26
Markdown
On this page 10 sections

Authored gateways can show one upright rectangular window per place, with up to two destination views in a chain during shared play. The player renders the destination and its travelers through the opening in both the graphics worker and main-thread fallback. The editor composes the same format-17 source, and Check portal views in the Observatory exercises the renderer independently. This is scoped milestone-12 coverage; the full portal milestone remains open. Crossing still uses the gateway interaction.

#Two-level views — early testing

The editor and player follow two authored window links automatically. Existing saved worlds need no format migration: enable a gateway window in place A and another in destination B. Position B's window beyond A's exit plane and face it toward the virtual viewer. A can then show C through B. Cycles stop after two views. The last view renders its ordinary geometry without a further aperture.

For a quick visible demonstration, run npm run dev:editor, open /diagnostics.html on that server and select Check portal views. The gallery shows a blue inner window beyond the outer garden gate. Normal editor previews, Save/reload and shared Apply use the same recursive path.

Each view retains independent presentation history and live traveler updates. The player multiplexes them over the existing single 4 MiB acknowledged transfer buffer. Baseline repair covers both cursors; replacement retires their stream identities together. Residency prepares the current place and both destinations, deduplicating repeated places and model bytes. Server interest checks each hop's condition; a denied intermediate gateway stops propagation. Capability 2 requests two hops, while capability 1 retains the old one-hop response. Persistent session admission preserves its legacy marker and separately advertises the new depth.

The per-view pixel/target limits below still apply: two active views can reserve up to 56 MiB in graph/output peak budgets, or 112 MiB with a replacement chain, excluding the root view, text atlases and the separate shared model pool. No frame-time or low-memory-device qualification is claimed.

Focused acceptance: 24 native checks cover projection, live second-hop actors, cycles, two-hop content residency, interest gates/legacy clients and portal math. The software-WebGPU gallery checks nested pixels, live nested updates, parent clipping and rejection beyond the depth limit, alongside existing recovery and resource cleanup. The software-WebGPU editor/shared-player smoke passes saved preview reload, shared Apply, Worker/main-thread admission, second-hop peer interest and returned presentation buffers. The test server also builds the client and packaged engine. Broader tests below are retained for the final pass.

Deferred review:

  • Physical/mobile GPUs, sustained frame time, aggregate memory under replacement, and large model catalogs across three places.
  • Full recursive travel/revision/device-loss matrices in both graphics placements and persistent/trusted rooms; slow asset delivery and cancellation at each depth.
  • The local traveler is still omitted from remote presentation when a cycle looks back into their own place. Portal-traveler metrics describe the first view.
  • More than two levels, multiple windows per place, oblique/scaled windows, continuous physical crossing and projectiles remain separate work.
  • Final environment/character/arrival art remains required and unapproved. The blue gallery rectangle is a functional rendering fixture, not finished game art.

#Camera and clipping

A PortalLink has a source window (position, yaw, width, height) and a destination frame (position, yaw). Frames use local place coordinates. The rigid mapping is destination × half-turn × inverse source. It preserves distances, vertical orientation, pitch and field of view. Source dimensions range from 0.1 to 100 units; this path does not scale the traveler or support tilted windows.

The virtual destination camera renders into a linear HDR target on the same GPU device as the source view. The destination positive half-space is kept with a 0.002-unit plane bias. The shared visual uniform now includes a clip plane; a zero plane leaves normal rendering unchanged. PBR, imported/skinned material variants, particles and lettering use the same fragment clip. Shadow rendering retains the destination scene's full geometry.

The source window writes depth in the ordinary scene pass, after opaque geometry and before particles/lettering. Local foreground geometry occludes it. Back-facing and off-screen windows skip destination work. Homogeneous clipping bounds projected coverage through near-plane crossings; the aperture's depth is clamped to the near plane so a close viewer does not lose the whole window. Normal destination near/far clipping remains in force.

Captured projective coordinates keep a cached destination frame attached to the window between refreshes. Destination output stays linear; the source tone mapping and bloom run once after composition. Nested windows use the same linear path and inherit their parent exit clipping plane. Per-pixel destination depth transfer and volumetric atmosphere blending remain open. Shared cargo crossings are documented separately in CARGO_CROSSINGS.md.

#Budgets and ownership

Tier Maximum destination pixels Maximum dimension Refresh ceiling
Performance 65,536 512 15 Hz
Balanced 131,072 768 20 Hz
High 262,144 1,024 30 Hz

The destination currently retains the renderer's fixed 2048² shadow map. Tier pixel limits apply to the scene/color target, not shadow resolution.

The full destination target retains the source aspect within integer-pixel rounding and never exceeds the tier's pixel budget. Projected source-window coverage is separately reported as a fraction of the main viewport. A viewport/quality resize or first visible frame refreshes immediately; otherwise the view respects its cadence. This limits offscreen work, not the fraction of the display that a nearby window can cover.

There is one active chain and at most one prepared chain. A shared depth limit bounds recursion at two camera hops, including cycles. A third view is rejected before allocating its renderer. Each destination runs its shadow, scene and linear-copy passes; the parent graph declares the borrowed portal target as a scene dependency. Changing that external dependency preserves the parent's allocated targets and their usages.

Destination graph targets have a 24 MiB peak allocation budget; the linear output has a 4 MiB peak allocation budget. Those limits include old and replacement frame targets. Imported model assets and text atlases retain their existing separate bounds. The active destination adds its buffers, textures and logical target bytes to parent RenderStats. portalState exposes destination dimensions, coverage, frames, preparation state and its resource census. These are owned-handle and logical-texel measurements, not driver memory or GPU timings.

The child borrows the device and owns its buffers, target and pipelines. Removing or discarding it cannot destroy the parent's device. Parent disposal/device loss releases the complete chain. Partial setup failures release newly allocated buffers even before a complete child renderer/compositor exists.

#Staging and failure

WebGPURenderer.preparePortal({link, scene, models}) validates and owns source, prepares a separate renderer with its model catalog, text atlas and surface shaders, and returns a commit/dispose handle. preparePortal(null) stages removal. The existing view remains accepted during preparation; while a successful candidate is staged, rendering pauses until commit/dispose. Commit occurs after the owner queue is idle. This is a rendering API: the caller must authorize the destination and coordinate residency and world revision transactions.

Preparation has a 20-second deadline, with existing 15-second shader deadlines. cancelPortalRevision() discards a pending/staged candidate while retaining the accepted view. Native GPU calls cannot be cancelled; their preparation slot stays occupied until the underlying work settles. Repeated cancellation cannot accumulate native builds. Failed shader compilation, text rasterization, rejected source and discarded candidates preserve the accepted view. Text rasterization and allocation complete before the candidate can commit. Frame targets still allocate on first visibility and resize under their separate bounds.

#Shared device model ownership

Source and destination renderers now share immutable model geometry and images through one device-owned pool. Content-addressed mesh variants retain their distinct reflected/skinned identities. Native validation must finish before a candidate becomes reusable. Unvalidated concurrent work remains separately reserved, and failure cannot retire another view's accepted allocation. Animation palettes and material bindings remain private so source and destination poses cannot overwrite one another.

The aggregate pool admits at most 64 MiB of unique model buffer/logical image bytes and 32,768 native handles, including pending candidates. Existing 32 MiB logical per-view replacement limits remain. The source resource census counts shared model handles once; modelState.device exposes bytes, peak, limit, handles, leases, pending validation and reuse count. This budget excludes frame targets and driver bookkeeping. Each view releases leases after its existing frame-retirement boundary; last release destroys the resource. Root device disposal closes the pool and fences late work.

The gallery checks real source/destination reuse, agreement with instrumented native handles, and a preserved destination image after the source catalog is retired. Its two logical catalogs total 3,962,984 bytes and require 2,243,668 unique bytes (about 43% lower). Complete network asset streaming and adjacent prefetch are still separate open work. See residency.

#Live destination presentation

preparePortal({link, stream, baseline, models, actors}) stages a live destination. The caller supplies its own unique stream identity, exact presentation baseline and optional {model, options} actor art profile. The child installs that baseline and validates its traveler rig before commit. This preserves the original static {link, scene, models} API used by editor previews. A live baseline and static scene cannot be mixed.

presentPortal(stream, message, receivedAt) feeds the accepted child through the same bounded presentation receiver, actor interpolation, skeletal animation and attachment path as the main view. It returns the child receiver's result; an absent/static destination or different stream returns undefined without reading the packet. A staged stream remains inactive until commit. A retired stream cannot replace the new destination even if its packet advertises a larger presentation generation. Missing topology updates request a baseline within the current stream. Stream identity and presentation generation serve different purposes: replacing the destination changes the stream; repairing its scene retains it.

The player-side PlayerPortalProjection reads the existing canonical entity store through a separate EcsPresentation cursor and shares immutable asset descriptions with the source projection. Each cursor filters geometry and actors to its own authored destination, applies that destination's environment and decorations, and never constructs another simulation or physics world. Its stream changes on replacement, and callbacks for a different simulation or changed source place publish nothing. Preparing a view after its source place changed rejects. Actual travel captures a new bounded chain from the new source place.

Native verification shows one rigged traveler with a lantern through the window, 2,079 changed pixels relative to the vacant aperture, two retained motion samples, animated walking and complete actor removal after departure. No traveler rig appears in the source scene. A missed destination delta repairs from its baseline. Staged/discarded/retired streams are fenced, invalid rig preparation preserves the prior frame and resource counts, and the live traveler reconstructs pixel-for-pixel after device loss at the same animation time. The rendered fixture was visually inspected; it uses the existing character art, whose final redesign remains open.

The renderer/projection foundation is recorded in its acceptance, native measurements and rendered traveler. The player lifecycle below connects those capabilities to normal shared play.

#Player activation, transfer ownership and recovery

At startup and device reconstruction, the simulation waits for the main graphics owner to bind a generation, then prepares the current source baseline and destination catalog/rig together. The native worker's allocation-ready notice does not authorize presentation. Only the completed bootstrap enables source and destination delivery. Main-thread presentation also waits for this boundary, returning an early packet and requesting a baseline instead of admitting it into an uninitialized renderer.

The destination has an independent acknowledged presentation channel and one 4 MiB transferable buffer. It is allocated only after a window is needed and retained for reuse until the simulation owner stops. Graphics-worker placement multiplexes the destination envelope and receipt on the direct simulation-to-graphics port. Main-thread placement returns the same buffer through the UI owner. Admission receipts do not wait for GPU completion. Each channel has its own transfer epoch, sequence, outstanding receipt and 20-second acknowledgement deadline. Retired graphics generations return ownership without applying packets; the current destination stream additionally fences retired windows. Replacing a simulation disconnects the old port and resets both readers before the new owner starts. Diagnostics exports include portal, portalTransport and the existing source transport separately.

Travel and shared revision candidates own their destination projection alongside the source simulation/projection. preparePlayerRevision prepares the portal first, then the source catalog, before committing both synchronously. Failure or cancellation disposes both candidates; a late native result cannot publish after its operation was revoked. A shared revision rejection preserves the previous window/stream, and rejected travel keeps the current place. Durable travel continues to commit its room receipt only after graphics preparation. Resuming requests complete source and destination baselines so peer changes during an upload converge immediately.

Device recovery rebuilds the accepted destination from the live canonical store. Simulation recovery reconstructs its projection and stream from the accepted world/checkpoint and re-admits remote travelers from the room. Recovery cannot complete until a frame from initialized graphics has been submitted and completed. Main-thread frame verification now owns an exclusive drawing interval, matching the worker placement; ordinary animation frames cannot race its queue-idle check.

The two-player browser journey uses one graphics worker and one main-thread renderer. It checks visible destination travelers without source-place duplicates, round-trip travel, source removal/reinstallation, rejected destination shader preparation during both travel and shared publication, successful retries, forced reconnect, and graphics and simulation recovery in both placements. Final destination pools each report one allocation, zero outstanding transfers, and equal sent/returned counts. Acceptance, measurements, worker view and main-thread view.

#Persistent and server-authoritative rooms

The same player path is now verified against real PostgreSQL-backed cooperative and trusted static-world rooms. A dedicated fixture publishes ordinary creator gateways through the durable ledger and runs two independent browser contexts in worker/main graphics placements. The journey verifies outgoing/return travel, destination traveler rendering, credential-only reload, a real socket reconnect, and device/simulation recovery in both placements. Failed destination shader preparation leaves the saved place unchanged; retry commits the return.

Both final source-view reports contain one destination rig and zero source rigs, and every destination transfer buffer has returned. These are software-WebGPU integration measurements, without injected network latency or a host-crash/backup scenario. The original persistent/trusted suites retain their separate transaction, latency and restoration coverage. Acceptance and measurements.

With PostgreSQL binaries and the supported browser available, run:

Terminal
npm run test:browser:persistent -- --portals
npm run test:browser:trusted -- --portals

Each command builds a retained engine and seeds a fresh temporary database. It does not add windows to existing rooms or edit the running local editor.

#Authored windows and editor preview

World format 17, with compiler identity portal-window-world.17, adds an optional window to an existing static, anchored gateway. Older world formats and gateway identities retain their existing compiler paths. The window source contains offset, yaw, width, height, and an exit frame with position and yaw in the destination place. The exit frame is deliberately separate from the travel arrival; changing a view does not move travelers. The editor can seed it from a selected arrival.

Offsets and exit coordinates are limited to ±100 metres, dimensions to 0.1–100 metres, and authored facing to one turn. Each place admits one window. The opening follows its anchor's resolved world translation and upright yaw, including parent transforms. Offsets and aperture dimensions use metres independently of ornament scale; resizing a small gateway marker does not collapse the window. Tilted or inverted anchor frames reject during compilation. Ordinary scene hierarchy and shear checks remain in force.

In Gateway…, enable Show a window into this place, set the aperture and optionally expand the frame controls. Frame selection positions the camera to inspect the opening. Preview is unsaved until Save; reload, durable undo/redo and gateway deletion use the existing authored transaction. Surface edits preserve format 17. Moving the anchor or its parents updates the view; the destination uses canonical compiled draws, its own model dependency set, lighting, particles, lettering and surface recipes. The renderer prepares the candidate portal and any required parent catalog before activating the preview.

To try it, restart the editor server on the current development branch, open Gateway…, enable the window, choose its dimensions/view frame and Preview gateway. Save, then Start shared room or Apply saved revision. Join from two browser profiles and send one traveler through the gateway. The other traveler can see them through the window. F2 reports Portal views and Portal travelers separately from current-place travelers. The original beta world is unchanged until a window is authored. No extra destination simulation or physics owner is created.

#Verification and remaining work

Headless checks cover owned/bounded frames, rigid and reciprocal camera mappings, clipping orientation, near-plane coverage, tier sizing, target rollback/disposal, and render-graph dependency changes without target reallocation. The native gallery compares rendered images for destination visibility, clipping of geometry/particles/text, source depth occlusion, movement, near-plane behavior, refresh gating and quality bounds. An independently rendered destination camera checks two perspectives without using the portal camera mapping: each 50×50 sample region must average less than 0.25 color levels of RGB error, with a maximum of two levels on locally smooth reference pixels (at least half of sampled channels). The measured maximum is one level in smooth interiors, 11 at edges and 0.0751 mean across the complete region. The original strict all-pixel comparison failed at silhouette edges; its report is retained.

Lifecycle checks inject partial setup, text rasterization and shader failures, hold/cancel a native pipeline, replace/restore views, destroy the real device and reconstruct the original image. Native allocation instrumentation checks the reported census and final disposal.

Run the focused browser tests with the supported Node 22 runtime:

Terminal
npx playwright test tests/browser/portal-rendering.spec.ts

The Observatory button uses isolated workers and no room admission. Cancellation terminates its worker and restores the controls. The regular owned-renderer acceptance case exercises the unchanged zero-clip path across all existing renderer fixtures.

Physical desktop/mobile GPUs, other browser vendors, frame-time targets, long soaks, general destination streaming, seamless physical crossings and the remaining extended portal requirements remain unverified or unimplemented here. No full roadmap milestone is closed by this foundation. See the current program tracker for the recorded acceptance and full-plan estimate.

End of Visible portals