Appearance
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:4178Open 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.
| Member | Behavior |
|---|---|
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.tier | Current resolved quality tier: low, mid or high |
ctx.gfx.gov | The governor, including stats() and debug(). createGame already owns its frame and lifecycle calls |
ctx.view | Current 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 field | Meaning |
|---|---|
w, h | Rounded 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 |
dpr | Base pixel ratio, limited by device DPR, tier, pixel budget and optional dprCap |
scale | Adaptive multiplier on the base ratio |
pixelW, pixelH | Integer 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:
| Tier | Maximum base DPR | Pixel budget |
|---|---|---|
low | 1.5 | 950,000 |
mid | 2 | 1,700,000 |
high | 2.5 | 2,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, callcamera.clearViewOffset()and resetcamera.zoom = 1if 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.distanceis 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. topandbottomreserve 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.
| Option | Default and meaning |
|---|---|
draw | true; 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 |
signal | Optional 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.
| Member | Responsibility |
|---|---|
view, tier, probe | Current 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.
| Options | Defaults and units |
|---|---|
ladder, level, hold | LADDER, 0, false; descending positive scale multipliers, initial index and adaptation hold |
windowMs, quietMs, spacingMs, gapMs | 500, 1000, 2000, 250 ms: decision window, startup hold, change spacing and timing-gap reset |
missShare, hardShare, cleanShare, cpuShare | 0.1, 0.4, 0.05, 0.6: missed-frame, severe-overload, recovery and CPU-budget fractions |
settleFrames | 5 frames ignored after a change |
upWaitMs, upWaitMaxMs, flipMs, maxFlips | 4000, 60000, 3000 ms and 3 reversals: recovery probes and backoff |
throttleShare, gpuSamples, gpuOverloadMs, verdictMs | 0.8, 3, 15 ms, 5000 ms: cadence classification and GPU-cost sampling |
statsMs | 2000 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.