Skip to content

Graphics ​

Use ctx.gfx to render a Three.js scene with a pixel budget, fit its camera to the available space, warm resources before play and release them when the game is removed. createGame manages the canvas, frame loop, resize events and graphics lifetime. Supply your own Three.js namespace with three: THREE; the SDK does not bundle Three.js. The examples use Three.js 0.180.0 and require WebGL 2.

Run a responsive 3D game ​

This example displays a cube, rotates it during play and counts taps or Space/Enter presses. Its camera keeps the scene below the score and inside the host's safe area. All geometry is generated locally.

In the complete SDK checkout, after npm ci --ignore-scripts, create artifacts/graphics-demo/ and save these files there. Public imports require that complete checkout or installed SDK packages, plus Three.js in your own project. Use the playable template to add a manifest and preview assets for distribution.

index.html:

html
<!doctype html>
<html lang="en">
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Game graphics demo</title>
  <body><script type="module" src="./bundle.js"></script></body>
</html>

main.js:

js
import * as THREE from 'three';
import { createGame } from 'portal-kit';

let renderer, scene, camera, cube, tap;
let score = 0;
const frame = { width: 4, height: 4, distance: 6 };

createGame({
  id: 'graphics-demo', build: '0.1.0', three: THREE,
  score: { unit: 'taps', format: 'int', higherIsBetter: true },
  async load(ctx) {
    renderer = ctx.gfx.renderer();
    scene = new THREE.Scene();
    scene.background = new THREE.Color('#07080c');
    camera = new THREE.PerspectiveCamera(50, 1, 0.1, 100);
    camera.position.z = frame.distance;
    cube = new THREE.Mesh(
      new THREE.BoxGeometry(1.4, 1.4, 1.4),
      new THREE.MeshNormalMaterial(),
    );
    scene.add(cube);
    tap = ctx.input.tap();
    ctx.hud.label('TAPS');
    ctx.gfx.fitCamera(camera, { frame, top: 100 });
    await ctx.gfx.warmUp(scene, camera);
  },
  start(ctx) {
    score = 0;
    cube.rotation.set(0, 0, 0);
    ctx.score(score);
  },
  update(dt, ctx) {
    if (!ctx.reducedMotion) {
      cube.rotation.y += dt * 0.7;
      cube.rotation.x += dt * 0.3;
    }
    if (tap.pressed) ctx.score(score += tap.presses);
  },
  resize(view, ctx) {
    ctx.gfx.fitCamera(camera, { frame, top: 100 });
  },
  render() {
    renderer.render(scene, camera);
  },
  dispose() {
    // The SDK releases the warmed scene and renderer; drop our references.
    renderer = scene = camera = cube = tap = null;
  },
});

Bundle and serve from the checkout root:

sh
npx --no-install esbuild artifacts/graphics-demo/main.js --bundle --format=esm --outfile=artifacts/graphics-demo/bundle.js --servedir=artifacts/graphics-demo --serve=127.0.0.1:4178

Open http://127.0.0.1:4178/ and tap to start. Resize the window: the cube should keep its shape and stay inside the available area. Switching away pauses rotation; returning resumes it in standalone mode. Stop the server with Ctrl+C. Use the host integration guide to test layout, quality, pause and disposal commands in an iframe.

Game graphics methods ​

Use these from load onward. The SDK calls resize(view, ctx) after loading and when size, insets or render scale change; refit the camera there. The SDK also redraws a resized canvas while paused.

MemberBehavior
ctx.gfx.renderer(options?)Create the renderer on ctx.canvas, attach it to the governor and return it. Later calls return the same renderer; only the first call's options apply
ctx.gfx.fitCamera(camera, spec?)Apply camera fitting using the current view; return the same camera
await ctx.gfx.warmUp(scene, camera, options?)Register the scene for disposal and warm its resources. Create the renderer first
ctx.gfx.own(...objects)Register additional objects with dispose() methods, such as composers or render targets. Returns no value; register each object once
ctx.gfx.tierCurrent resolved quality tier: low, mid or high
ctx.gfx.govThe governor, including stats() and debug(). createGame already owns its frame and lifecycle calls
ctx.viewCurrent view; read it again after resize rather than retaining an old snapshot

Renderer options default to antialias: true, alpha: false, powerPreference: 'high-performance' and stencil: false. Other options are forwarded to THREE.WebGLRenderer. Output uses THREE.SRGBColorSpace when supported. Tone mapping stays at Three.js's default unless you pass toneMapping explicitly. Leave canvas and gov to the SDK when using ctx.gfx.renderer().

Do not also call setSize, setPixelRatio, setAnimationLoop or start another animation loop on this renderer. Resize game-owned render targets in resize using view.pixelW and view.pixelH. For scene detail such as particle counts, read ctx.gfx.tier in load and use quality(tier, ctx) for tier changes. Follow the callback requirements when a change arrives during loading. Automatic render-scale changes call resize, not quality.

CSS size and render resolution ​

Layout and input use CSS pixels. The canvas drawing buffer uses render pixels. Insets reduce the safe layout rectangle; they do not crop the canvas or change its buffer size.

View fieldMeaning
w, hRounded CSS width and height, at least 1
insets{ top, right, bottom, left } in CSS pixels, clamped within the view
safe{ x, y, w, h } in CSS pixels after insets; width or height can be zero
dprBase pixel ratio, limited by device DPR, tier, pixel budget and optional dprCap
scaleAdaptive multiplier on the base ratio
pixelW, pixelHInteger drawing-buffer dimensions; use these instead of recomputing rounded sizes

computeView({ w, h, deviceDpr = 1, tier = 'mid', dprCap = null, scale = 1, insets }) is a pure export from portal-kit/gfx. Supply finite positive dimensions and DPR, a supported tier, and a positive scale at most 1. A positive dprCap lowers the base ratio; null leaves it uncapped. Missing or invalid inset sides become zero; opposite insets cannot overlap (top and left take priority).

The exported TIERS defines these base limits; pixel budgets are approximate because buffer sizes round:

TierMaximum base DPRPixel budget
low1.5950,000
mid21,700,000
high2.52,650,000

The base ratio is the minimum of device DPR, tier DPR, the square root of budget divided by CSS area, and any positive cap. Scaling has a floor of one render pixel per CSS pixel, or the base ratio when it is already below one. A scale of 0.6 therefore does not always produce 60% of the base buffer dimensions.

Run this as a Node ES module after installing the SDK. It prints 880 1912 804:

js
import { computeView } from 'portal-kit/gfx';

const view = computeView({
  w: 440, h: 956, deviceDpr: 3, tier: 'mid',
  insets: { top: 62, bottom: 90 },
});
console.log(view.pixelW, view.pixelH, view.safe.h);

Fit a camera ​

fitCamera(camera, view, spec = {}) updates the projection matrix and returns the camera. The context wrapper supplies view for you. The helper changes projection, not camera position, rotation or clipping planes.

  • Horizontal field of view: { hfov: 50 } is the default. For a perspective camera, preserve that horizontal angle across viewport shapes. Angles are in degrees; this mode does not reserve safe insets. It preserves existing zoom/view offsets. When switching from frame fitting, call camera.clearViewOffset() and reset camera.zoom = 1 if you want a centered, unzoomed projection.
  • World frame: { frame: { width, height, distance: 1 }, top: 0, bottom: 0 } fits a rectangle centered on the view axis inside the safe area. Width/height/distance are positive world units. distance is from a perspective camera to the rectangle's plane; it does not move the camera. Frame fitting also supports orthographic cameras, where distance is unused. It resets zoom to 1 and offsets the perspective projection to center the rectangle in the safe area.
  • top and bottom reserve additional CSS pixels within the safe area, for a HUD or controls. They apply only to frame fitting. A combined margin that leaves no height is ignored. A safe area with zero width or height falls back to the full view for camera fitting; avoid placing interactive controls there.

For camera calculations without a camera object, hfovOf(vfov, aspect) and vfovOf(hfov, aspect) convert between horizontal and vertical angles in degrees. aspect is CSS width divided by CSS height.

Warm resources before play ​

warmUp(renderer, scene, camera, options = {}) uploads textures, compiles materials in small batches and performs small scissored draws to allocate buffers and shadows. Hidden renderable objects are included; visibility, culling and scissor settings are restored after each draw. It uses timers, so it does not need an active animation frame. Call it after building the scene and fitting the camera, before starting play.

OptionDefault and meaning
drawtrue; false skips the draw phase, leaving first-draw allocations for later
breathe(label)Async yield between steps; defaults to a timer. The context wrapper supplies the host-aware loading yield
signalOptional AbortSignal checked between steps; the context wrapper supplies ctx.signal
report({ done, total, slowest })Optional callback after step work; counts work units, not bytes. The context wrapper reports shader progress automatically

The Promise returns { textures, programs, ms, steps }: uploaded texture count, renderer program count, elapsed milliseconds and yielded step count. An abort or lost context stops at a step boundary and can return partial counts; resolution alone does not prove loading completed. Check your signal after external async work. The context yield can remain pending after disposal, as described in loading. Normally pass only draw to the context wrapper; overriding breathe, signal or report replaces its integration.

Warm-up cannot split a single expensive driver operation or guarantee that every shader is finished within its bounded polling period. It skips eager uploads for video and render-target textures. Resources created later still need preparation. Measure the first visible frames and follow cooperative loading.

Quality and diagnostics ​

The governor sizes the drawing buffer and adapts scale along the exported LADDER: [1, 0.85, 0.75, 0.6]. It uses frame intervals and CPU callback duration to reduce pixel work under sustained rendering overload, wait before trying a higher resolution, and limit repeated reversals. CPU-bound frames and a clean throttled 30 Hz cadence need different remedies from GPU overload; reducing resolution is not a universal performance fix. The governor can synchronously sample GPU completion when distinguishing these cases.

Read ctx.gfx.gov.stats() for fps, p95 frame interval in milliseconds, over50 intervals over 50 ms, scale, dpr, tier, throttled, and last-frame calls/triangles. Timing statistics cover the recent two-second window by default. With a renderer attached, stats().gl also includes geometries, textures and programs; this property is non-enumerable and is omitted by JSON.stringify. debug() adds controller state, the last scale change and drawing-buffer dimensions for troubleshooting. The test hook exposes performance and governor diagnostics in test mode.

detectTier({ hint = 'auto', lose = false } = {}) probes once using a temporary WebGL 2 canvas and returns { tier, probeMs, maxTexture, gpu, software, mobile, dpr, webgl2 }. A low, mid or high hint skips probing; the returned probe fields then describe an unperformed probe. pickTier(info) is the pure decision helper: no WebGL 2, software rendering, texture limits below 8192 or probe time above 12 ms select low; otherwise desktop selects high, while mobile selects mid for DPR at least 3 or a probe under 4 ms, and low otherwise. A low tier is not a WebGL fallback renderer. These heuristics do not replace testing on target phones.

Use graphics with your own engine ​

Import helpers from portal-kit/gfx when your engine already owns its loop. Create a governor with a canvas whose CSS size is set by your page, then call createRenderer(THREE, { canvas, gov, ...options }). It uses the same renderer options as the context method; optional onContextLost(event) and onContextRestored(event) let your adapter stop work and coordinate recovery. The factory attaches the renderer automatically. Without gov, you own sizing. The helpers do not implement the host lifecycle.

Governor methods ​

createGovernor({ canvas, tier = 'auto', dprCap = null, insets, onResize, onTier, hold = false, controller }) returns the following interface. controller supplies the pure controller's options.

MemberResponsibility
view, tier, probeCurrent view, resolved tier and boot probe result
attach(renderer)Attach and size one renderer. Takes ownership of its info counter resets to count all passes in a frame
frame(nowMs, cpuMs = 0)Call once after all draws. Use the animation-frame timestamp and measured CPU time for the complete update/render callback, both in milliseconds
count()Count/reset renderer draw counters after an out-of-loop draw, such as warm-up
hold(true)Measure frames without automatic scale changes while not playing; hold(false) enables adaptation
runStart()Reset startup timing and reversal budget on start/resume; does not reset scale or release a hold
stepDown(nowMs)Request one lower scale even while held, for a slow readiness gate. Returns false at the bottom or after disposal
setInsets(insets)Replace insets and refresh size; omitted sides become zero
setTier(tier, dprCap?)Change tier and refresh. 'auto' restores the boot tier without probing again. Omitted cap keeps it; null clears it
stats(), debug()Read diagnostics
dispose()Cancel pending sizing and remove the window resize listener. Does not dispose the renderer or scene

onResize(view) runs synchronously during governor creation, then whenever view values change. Do not access the not-yet-assigned governor/renderer in that first callback. onTier(tier) runs only when a later setTier changes the resolved tier. Apply initial scene quality yourself.

Scale changes are applied in a task between frames because resizing clears the canvas. Refit your camera and draw once in onResize when your loop is stopped. Window resize events refresh the view automatically; for CSS container changes without a window resize, call gov.setInsets(gov.view.insets) to refresh. On pause, stop scheduling and hold adaptation. On resume, reset your clock, call runStart(), release the hold and resume scheduling. A bridge adapter must also implement readiness, input, audio and host command handling.

Pure quality controller ​

createController(options = {}) has no browser dependency. It returns frame(nowMs, cpuMs = 0), runStart(), hold(on), ease(nowMs), setSampling(on), gpuSample(ms), stats() and debug(), plus getters scale, level, throttled and wantsGpuSample.

frame returns null or { scale, from, level, reason, flips, t } for a scale change; t is milliseconds. ease requests one step down, including during a hold, and returns the same change shape or null at the floor. If you enable sampling, supply CPU-plus-GPU completion cost to gpuSample only while requested. Use the governor unless your engine can provide these timings and apply changes safely between frames.

OptionsDefaults and units
ladder, level, holdLADDER, 0, false; descending positive scale multipliers, initial index and adaptation hold
windowMs, quietMs, spacingMs, gapMs500, 1000, 2000, 250 ms: decision window, startup hold, change spacing and timing-gap reset
missShare, hardShare, cleanShare, cpuShare0.1, 0.4, 0.05, 0.6: missed-frame, severe-overload, recovery and CPU-budget fractions
settleFrames5 frames ignored after a change
upWaitMs, upWaitMaxMs, flipMs, maxFlips4000, 60000, 3000 ms and 3 reversals: recovery probes and backoff
throttleShare, gpuSamples, gpuOverloadMs, verdictMs0.8, 3, 15 ms, 5000 ms: cadence classification and GPU-cost sampling
statsMs2000 ms of recent frame intervals

Options are not generally validated; keep finite positive timings and a nonempty descending ladder of scales no greater than 1. Tune only with measurements from representative devices.

Release GPU resources ​

With createGame, every scene passed to ctx.gfx.warmUp is owned until disposal. Objects that remain in those scenes are discovered at teardown, including ones added later. Register extra disposable resources with ctx.gfx.own(...). Drop your references in dispose(ctx); leave owned scene contents intact until the SDK finishes cleanup. Resources removed from the scene before teardown must be disposed by you or registered separately. Avoid sharing owned GPU resources with another live game.

For your own engine, stop the loop, cancel pending loading, then call disposeScene(renderer, scene, extra = [], lose = LOSE_CONTEXT). It releases scene geometries, materials, textures (including material uniforms, background/environment and skeleton textures), light shadows, instanced-mesh resources and extras with dispose(), then disposes the renderer. Extras are not recursively traversed: pass a disposable resource, not an arbitrary group. Release resources only once and do not render after disposal. Then dispose the governor, shrink the canvas to 1 × 1, detach it if owned and drop references.

LOSE_CONTEXT is false: ordinary cleanup does not deliberately lose the WebGL context. Keep that default for the game's resource lifecycle. createGame performs the governor/canvas cleanup for you.

Wire protocol 2 · Manifest 2 · SDK runtime 0.2.1