Game Engine Integration
Rinch supports two complementary integration patterns for game engines and custom renderers:
- Embed API — Your game owns the window and GPU. Rinch provides UI as a Vello scene you composite on top.
- RenderSurface — Rinch owns the window. Your game/renderer submits frames (CPU pixels or GPU textures) into a DOM component.
RenderSurface (Recommended)
RenderSurface is a component that embeds external pixel content into rinch’s layout. Your renderer submits frames via a thread-safe SurfaceWriter (CPU pixels) or GpuTextureRegistrar (GPU textures), and rinch composites them during paint. Input events (mouse, keyboard) are routed back to your event handler.
This is the simpler pattern — rinch handles windowing, layout, and event dispatch. You just provide pixels and handle surface-local events.
Quick Start
use rinch::prelude::*;
#[component]
fn app() -> NodeHandle {
let surface = create_render_surface();
// Handle input events from the surface
surface.set_event_handler(|event| {
match event {
SurfaceEvent::MouseDown { x, y, button } => { /* handle click */ },
SurfaceEvent::MouseMove { x, y } => { /* handle hover */ },
SurfaceEvent::MouseWheel { delta_y, .. } => { /* handle zoom */ },
SurfaceEvent::KeyDown(key) => { /* handle keyboard */ },
_ => {}
}
});
// Submit frames from a worker thread
let writer = surface.writer();
std::thread::spawn(move || {
loop {
let pixels = render_frame(); // your renderer
writer.submit_frame(&pixels, width, height);
std::thread::sleep(std::time::Duration::from_millis(16));
}
});
rsx! {
div { style: "display: flex; height: 100%;",
div { style: "width: 200px;",
// Sidebar with rinch UI controls
Button { onclick: || do_something(), "Tool" }
}
RenderSurface { surface: Some(surface), style: "flex: 1;" }
}
}
}
fn main() {
App::new(app).title("My App").size(1280, 720).run();
}
CPU Pixel Submission
SurfaceWriter is Send + Sync + Clone — safe to use from any thread:
#![allow(unused)]
fn main() {
let surface = create_render_surface();
let writer = surface.writer();
// From any thread:
let pixels: Vec<u8> = render_rgba(width, height);
writer.submit_frame(&pixels, width, height);
}
Pixels are RGBA8, row-major. The surface redraws automatically after submit_frame().
GPU Texture Compositing
For zero-copy compositing, use GpuTextureRegistrar to provide a wgpu::TextureView directly:
#![allow(unused)]
fn main() {
let surface = create_render_surface();
let registrar = surface.gpu_registrar();
// Get the shared wgpu Device via gpu_handle(). It is None while the window
// presents with the software renderer (RINCH_RENDERER=cpu, or the fallback
// when the GPU would not start), so handle that case.
let Some(gpu) = gpu_handle() else {
// No GPU device: submit CPU pixels through surface.writer() instead.
return;
};
let device = &gpu.device;
let queue = &gpu.queue;
// Create your texture and render to it
let texture = device.create_texture(&wgpu::TextureDescriptor { /* ... */ });
// ... render into texture ...
// Register the texture for compositing — pass BOTH the texture and its view.
// rinch needs the texture for the GPU→CPU readback path (inline-painted
// RenderSurface) and the view for compositing it directly.
let view = texture.create_view(&Default::default());
registrar.set_texture_source(texture, view, width, height);
registrar.notify_frame_ready();
}
GpuTextureRegistrar is also Send + Sync + Clone. The texture must be created on the same wgpu::Device (available via gpu_handle()).
gpu_handle() returns None until the window’s GPU renderer has started, and for the whole session when the window presents with the software renderer: Renderer::Software was chosen, a user set RINCH_RENDERER=cpu, or the GPU would not start and Renderer::Auto fell back (see Rendering Backends). An app that configures the device with App::gpu_config or App::external_gpu (below) is exempt: it always presents on the GPU, or panics at startup if the GPU will not start, so once its window is up the handle is there.
Use
rinch::wgpu. rinch pins a patchedwgpufork. Construct everywgputype you hand back to rinch (TextureView, config features/limits, …) fromrinch::wgpu::…, not a separately-pinnedwgpudependency, or the types won’t match.
Sharing a High-Capability GPU Device
Zero-copy compositing requires your texture to live on the same device rinch composites with. By default rinch requests that device with Features::default() / Limits::default(). If your renderer needs more (extra features, larger storage buffers, more bind groups, …), raise the device’s requirements at startup so the shared device — the one published via gpu_handle() — can host your pipelines too.
Let rinch own the device (recommended). rinch still creates the instance, picks a surface-compatible adapter, and creates the device, so presentation is always correct — you only add the capabilities you need:
#![allow(unused)]
fn main() {
use rinch::prelude::*;
use rinch::wgpu;
let gpu = RinchGpuConfig {
required_features: wgpu::Features::FLOAT32_FILTERABLE,
required_limits: wgpu::Limits {
max_storage_buffers_per_shader_stage: 32, // wgpu default is 8
..Default::default()
},
};
App::new(app).gpu_config(gpu).run();
}
After startup, gpu_handle() returns the shared device, queue, and adapter (use adapter.limits() to clamp, e.g. max_buffer_size = min(adapter, 2 GiB)). The requested features/limits are passed through verbatim — if the adapter can’t satisfy them, device creation fails loudly rather than silently dropping a capability.
Bring your own device. If you must keep your renderer’s exact DeviceDescriptor, build the whole GPU stack and hand it to rinch. rinch creates only the window surface (from your instance), validates that your adapter can present to it, and composites directly onto your device — no request_device, no CPU readback:
#![allow(unused)]
fn main() {
use std::sync::Arc;
use rinch::prelude::*;
let gpu = ExternalGpu {
instance, // rinch creates the window surface from this
adapter: Arc::new(adapter), // must be able to present to the window
device: Arc::new(device),
queue: Arc::new(queue),
};
App::new(app).external_gpu(gpu).run();
}
The provided device is published through gpu_handle(). On a single-GPU machine a headless adapter (compatible_surface: None) usually presents fine; on multi-GPU systems create the adapter from a surface-compatible one, or prefer App::gpu_config (which guarantees compatibility). See the gpu-device-config example for both modes.
Layout Size
Query the surface’s current layout dimensions (set by CSS/Taffy) to match your render resolution:
#![allow(unused)]
fn main() {
let (w, h) = surface.layout_size();
// or from the registrar:
let (w, h) = registrar.layout_size();
}
Surface Events
Events are dispatched to the handler set via set_event_handler(). Coordinates are in logical pixels relative to the surface’s top-left corner.
| Event | Fields | Description |
|---|---|---|
MouseDown | x, y, button | Mouse button pressed |
MouseUp | x, y, button | Mouse button released |
MouseMove | x, y | Mouse moved over surface |
MouseWheel | x, y, delta_x, delta_y | Scroll wheel |
MouseEnter | x, y | Cursor entered surface |
MouseLeave | — | Cursor left surface |
KeyDown | SurfaceKeyData | Key pressed (when focused) |
KeyUp | SurfaceKeyData | Key released (when focused) |
TextInput | String | Text input (when focused) |
FocusGained | — | Surface received keyboard focus |
FocusLost | — | Surface lost keyboard focus |
A press keeps the pointer. From a press on the surface until the release of the button
that started it, every move and release of that pointer go to the surface wherever it is
(coordinates then fall outside the surface’s size), and it hears no MouseLeave until
then; a MouseLeave follows a release off the surface. A press or release of another button
meanwhile is a chord: the surface hears it and keeps the pointer. The browser backend does
this with setPointerCapture. Desktop routes the surface’s events the same way, but
not the document’s: DOM hover (:hover, onmouseenter / onmouseleave) and the cursor
still follow the element under the pointer during the press, where a browser routes them
to the capturing canvas.
A press whose release never arrives is ended without one, and the surface hears it ended —
PointerCancel with pointer events, else a MouseUp where the cursor is: on desktop when
the window loses focus, when the same pointer presses the left button again (another
finger’s press proves nothing), on a platform PointerCancel (Android’s touch-scroll
takeover), and silently when the surface unmounts; in the browser on pointercancel.
Pen, touch and pinch: pointer events (opt-in). A drawing surface wants to know which
device pressed and how hard. surface.set_pointer_events(true) replaces MouseDown /
MouseMove / MouseUp with these (every other surface hears exactly what it did before):
| Event | Fields | Description |
|---|---|---|
PointerDown | x, y, button, pointer | A button, pen tip or finger went down |
PointerMove | x, y, pointer | It moved (or its pressure changed) |
PointerUp | x, y, button, pointer | The press ended |
PointerCancel | pointer | The platform took the pointer away mid-press; no PointerUp follows |
Pinch | x, y, scale | Two touches spreading or closing, a trackpad pinch, or Ctrl+wheel; scale multiplies the zoom (above 1 is in), relative to the last Pinch |
SurfacePointer is { id, kind, pressure, primary }: kind is Mouse, Pen, Eraser,
Touch or Unknown; pressure is 0.0..=1.0, and a device that cannot measure it
reports 0.5 while down and 0 otherwise (the Pointer Events rule); id tells pointers
that are down at once apart (two fingers) and stays the same from a press to its release.
Compare ids for equality only: their values are the platform’s. On desktop the mouse is
1, a tablet tool 2 and each finger an id of its own above both; in the browser id is
the event’s pointerId, whose value for a mouse differs between engines. What each host
reports:
- Browser: Pointer Events (
pointerType,pressure,pointerId,isPrimary, the eraser button); two touches pinch; Ctrl+wheel (a trackpad pinch) pinches. - Desktop: winit’s pointer source (a tablet tool’s force, a touch’s force where
measured); two touches pinch; a trackpad pinch (macOS, iOS, Wayland) and Ctrl+wheel
pinch. One move per pointer per frame, as the browser does without
getCoalescedEvents. - Android: every finger is folded into one mouse pointer (id
1, kindMouse) by the touch translation, so there is noTouchkind and no two-fingerPinch. - Embed (
RinchContext): the host feeds plain mouse events, so every pointer is the mouse (Mouse, id1); Ctrl+wheel still pinches.
SurfaceKeyData contains key, code, ctrl, shift, alt, meta. key is spelled like the browser’s KeyboardEvent.key on both backends, so the space bar is key == " " and code == "Space".
A focused surface only swallows the keys it claims (issue #482). set_event_handler
keeps receiving every KeyDown/KeyUp exactly as above — that delivery is unaffected.
set_key_handler(handler: impl Fn(&SurfaceKeyData) -> bool) is a separate, additive
registration asked after: should this key stop at the surface? Leave it unset — the
default — and the surface claims nothing, so a host’s own window-level keybindings (and,
on the web, the browser’s own reload/find/… shortcuts) keep working the moment the canvas
is focused, and on desktop so do DevTools (F12), inspect mode (Alt+I) and Tab:
#![allow(unused)]
fn main() {
surface.set_key_handler(|key| matches!(key.code.as_str(), "KeyW" | "KeyA" | "KeyS" | "KeyD" | "Space"));
}
The document-level keyboard interceptor (set_keyboard_interceptor) is unaffected either
way — it already sees every key before a focused surface does, on both backends.
On the web, an unclaimed key also keeps the browser’s default action — no longer
preventDefault()ed just because the surface is focused, matching an unfocused <canvas>.
Space, PageUp/Down and the arrow keys can scroll the page, and Tab leaves the canvas. A web
game steering with any of those must claim them via set_key_handler, or the page scrolls
under it.
Web (canvas viewport)
The same RenderSurface + create_render_surface() API works on rinch-web, but the model is inverted. On the web the browser composites, so rinch only creates and manages a real <canvas> “viewport hole” sized by layout; the app owns the GPU context (rinch links no wgpu on web). This mirrors desktop symmetrically: desktop = rinch owns the window and you submit frames; web = you own the canvas surface.
#![allow(unused)]
fn main() {
use rinch::prelude::*;
use rinch::render_surface::{create_render_surface, RenderSurface};
let surface = create_render_surface();
// After mount, grab the <canvas> and create your own wgpu WebGPU surface on it.
// (canvas_element() returns None until the component has mounted.)
if let Some(canvas) = surface.canvas_element() {
let wgpu_surface = instance
.create_surface(wgpu::SurfaceTarget::Canvas(canvas))?;
// request adapter/device (async on web), configure, and render each frame.
}
// Resize notification (ResizeObserver-driven). Size is PHYSICAL px
// (CSS px × devicePixelRatio), matching desktop — HiDPI-correct out of the box.
surface.set_resize_callback(move |w, h| reconfigure_wgpu_surface(w, h));
// Per-frame tick (drives requestAnimationFrame on web).
surface.set_render_callback(move |_writer, w, h| render_triangle(w, h));
rsx! { div { style: "flex:1;", RenderSurface { surface: Some(surface) } } }
}
Notes:
- HiDPI out of the box.
layout_size()reports physical pixels (CSS px × devicePixelRatio), rinch sizes the canvas backing store to match, andset_resize_callbackpushes the new physical size on every resize so you can reconfigure your wgpu/WebGL surface. - Input (pointer / wheel / keyboard incl.
KeyUp/ focus) over the canvas is delivered toset_event_handler— rinch does not swallow it. Click the canvas to focus it for keyboard events. - CPU path still works & is portable.
SurfaceWriter::submit_frameblits via a lazily-created 2D context; it self-disables once you claim a WebGPU/WebGL context. So the same app can run on desktop and web. - Clean teardown. The ResizeObserver and canvas listeners are removed when the component unmounts — no leaks.
- See
examples/webgpu-surface-web(a WebGPU triangle in a rinch DOM UI) — the web counterpart ofexamples/game-embed. Build withtrunk serveoverlocalhost(sonavigator.gpuis available; thewebglcargo feature is a fallback).
API Reference: RenderSurface
| Function / Type | Description |
|---|---|
create_render_surface() | Create a new surface handle |
RenderSurfaceHandle | Main handle — set event handler, get writer/registrar |
SurfaceWriter | Thread-safe CPU pixel submission (Send + Sync + Clone) |
GpuTextureRegistrar | Thread-safe GPU texture registration (Send + Sync + Clone) |
RenderSurface | Component — use in RSX with surface: Some(handle) |
SurfaceEvent | Input event enum dispatched to handler |
RenderSurfaceHandle methods:
| Method | Description |
|---|---|
writer() | Get a SurfaceWriter for CPU pixel submission |
gpu_registrar() | Get a GpuTextureRegistrar for GPU texture compositing |
set_event_handler(handler) | Set input event callback (main thread closure) |
set_key_handler(handler) | Fn(&SurfaceKeyData) -> bool; whether a key already delivered to set_event_handler should stop here instead of continuing to a window-level host keybinding (web) or DevTools/inspect/Tab (desktop). Unset: claims nothing (issue #482) |
set_render_callback(cb) | Per-frame FnMut(&SurfaceWriter, w, h) — drives requestAnimationFrame on web |
set_resize_callback(cb) | FnMut(w, h) fired on backing-size change (physical px) — reconfigure a GPU surface |
layout_size() | Get current physical (width, height) (web: CSS px × devicePixelRatio) |
canvas_element() | (web only) the <canvas> after mount — create a WebGPU/WebGL context on it |
set_texture_source(texture, view, w, h) | (desktop, gpu) Set GPU texture directly (main thread only) |
has_texture_source() | Check if a GPU texture is registered |
id() | Surface ID |
viewport_name() | Internal viewport name |
Embed API
The embed API is for when your game owns the window and wgpu device. Rinch runs headless — you feed it platform events, it produces a Vello scene, and you render/composite it yourself.
Enable with: features = ["desktop"]
Quick Start
use rinch::prelude::*;
use rinch::embed::{RinchContext, RinchContextConfig, RinchOverlayRenderer};
#[component]
fn game_hud() -> NodeHandle {
let health = Signal::new(100);
rsx! {
div { style: "position: absolute; top: 10px; left: 10px;",
Text { size: "lg", color: "white", {|| format!("HP: {}", health.get())} }
}
}
}
fn main() {
// Your game creates the window and wgpu device
let (device, queue, surface, window) = my_engine::init();
// Create rinch UI context
let mut ctx = RinchContext::new(
RinchContextConfig {
width: 1280,
height: 720,
scale_factor: window.scale_factor(),
theme: None,
},
game_hud,
);
// Create overlay renderer from your device
let mut overlay = RinchOverlayRenderer::new(
&device, 1280, 720, wgpu::TextureFormat::Rgba8Unorm,
);
// Game loop
loop {
let events = collect_platform_events(&window);
let actions = ctx.update(&events);
for action in &actions {
match action {
AppAction::SetCursor(cursor) => { /* set cursor */ },
AppAction::Exit => return,
_ => {}
}
}
game.render(&device, &queue);
let ui_texture = overlay.render(&device, &queue, ctx.scene());
composite(&device, &queue, &surface, game_texture, ui_texture);
}
}
RinchContext
RinchContext is the main handle. Create it during initialization, call
update() each frame.
Multiple contexts. Several RinchContexts can coexist on one thread — a
screen HUD plus N render-to-texture panels, say. Each context tracks signal
changes through its own subscription (so creating or dropping one never
silences another) and keeps its own document-scoped element-bounds signals,
editor registrations, and focus requests. Stores/contexts are namespaced
per context (#136): a create_store inside a context’s component lands in
that context’s own namespace, its effects and event handlers resolve that
namespace first, and lookups fall back to the thread-global namespace for
stores created outside any context (e.g. before mounting). Two contexts can
therefore create the same store type without overwriting each other, and
dropping a context clears its namespace. One caveat remains: each context
still processes the input events you feed it — route each window’s events
only to its own context.
Background threads. The documented cross-thread pattern works in embed:
Signal::send() / Signal::update_send() from a worker, and
run_on_main_thread() for anything else, marshal the write onto the thread that
created the context and apply it at the top of the next update() — before
the events of that frame, so a queued write and an event handler are absorbed by
the same layout pass. Everything that rides that transport comes with it:
set_timeout, rinch-http completions, rinch-ws events. Signal::set() and
Signal::update() still panic off the main thread — only the _send variants
marshal.
The queue is process-global, so a RinchContext you drive drains work queued
against any context on the thread. That is fine: the payload writes its own
signals, signals are thread-local rather than per-document, and the document it
touches is still repainted by its own update().
Before issue #172 an embedded context registered the main thread but no dispatcher, and a background-thread
send()panicked on the calling thread. If you pinned an older rinch and wrapped your worker writes to avoid it, you can drop the workaround.
Timed work: the caret blink. A focused rich-text Editor’s caret toggles
every 530 ms, and update() ticks that clock just as the desktop runtime does
after each event-loop turn (issue #331). A host that calls update() every
frame needs nothing more. An event-driven host — one that renders only when
input arrives — asks ctx.next_wake() after each update(): it is the instant
the caret next toggles (what desktop arms its ControlFlow::WaitUntil with), or
None when nothing is blinking. Schedule a wake for it, or the caret toggles
only when some unrelated event happens to arrive. needs_update() answers
true once that instant has passed. A blurred host window
(PlatformEvent::WindowFocus(false)) stops the blink with the caret solid and
arms no wake. Each context has its own blink clock (issue #1149): two contexts
on one thread that each have a focused editor both blink, each on its own
phase, and blurring or typing in one leaves the other’s blink alone.
RenderSurface in an embedded context. A RenderSurface works inside a
RinchContext the way it does on desktop: scene() tells each surface in
that context’s document its physical layout size, runs its render callback, and
paints its latest frame inline at the surface’s box (issue #331). Call scene()
once per frame — every call runs the render callbacks again. A frame submitted
from another thread makes needs_update() true and the next update() answer
RequestRedraw. Several contexts on one thread each drive only their own
surfaces, and a thread with no RenderSurface at all pays nothing for this in
scene().
Three things are not composited by rinch in an embedded context, because
the host owns compositing there: a surface registered under a data-viewport
name (create_render_surface_with_name), a <video> / VideoViewport (whose
frame sink is installed only by the desktop runtime), and a
GpuTextureRegistrar texture on a RenderSurface (read back only through the
desktop runtime’s own device, so in an embedded context it draws nothing, and
its notify_frame_ready asks for no redraw). Treat them like a GameViewport
hole: render into your own target and composite it under rinch’s overlay.
Input Routing
For HUD overlays, use wants_mouse and wants_keyboard to decide whether input goes to the UI or the game:
#![allow(unused)]
fn main() {
if ctx.wants_mouse(mouse_x, mouse_y) {
ctx.update(&[mouse_event]); // UI element under cursor
} else {
game.handle_mouse(mouse_x, mouse_y); // game content
}
if ctx.wants_keyboard() {
ctx.update(&[key_event]); // text input focused
} else {
game.handle_key(key); // game shortcuts
}
}
A mouse-clicked plain button does not claim
wants_keyboard()— a Tab-focused one still does (issue #548). A<button>/<a href>is focusable with notabindexneeded, so a single click on one (no Tab involved) used to makewants_keyboard()answertrueuntil the next click landed somewhere else — a host following the pattern above then stopped seeing its own Esc/hotkey presses the moment the user clicked any button in a HUD. The fix follows the browser’s own:focus-visiblesplit rather than “is a node focused” or “did it register anything”:
- A mouse click on a plain button/link only ever consumes Enter/Space through rinch’s own activation path, so it does not need the host’s keyboard —
wants_keyboard()isfalse.- Tab (or a programmatic
NodeHandle::focus()) onto that same plain button sets its keyboard-focus ring (is_focus_visible), andwants_keyboard()istrue— so Tab navigation and Enter/Space activation of ordinary controls keep reaching rinch under the pattern above, exactly as before #548’s fix.- A custom widget that registered its own
FocusEntry::on_key(register_focus_target— arrow-key navigation, a shortcut of its own) istruewhichever way it was focused: the runtime can’t know which keys it wants without seeing them.- An open native
<select>popup is alwaystruetoo — its own arrow/Enter/Escape handling needs every key (a separate, pre-existing gap folded into the same fix).
A viewport hole must stay hittable — that is what routes the mouse.
wants_mouseonly reports “the game wants this” when the hit-tested node has adata-viewportancestor. A hole that is not hittable inverts the routing: the hit falls through tobodybehind it, the walk up frombodyfinds nodata-viewport, andwants_mouseanswerstrue— everywhere. The game silently receives no mouse input at all, with nothing in the layout or the render looking wrong.Since #207
GameViewportneeds no defending: everydata-viewportnode getspointer-events: auto; background: transparent;from the UA stylesheet, so bothGameViewport { name: "main" }andGameViewport { name: "main", style: "flex: 1;" }route correctly. Because those defaults are a UA rule rather than an inline style, restyling the hole cannot strip them — worth knowing, since a component’s universalstyle:prop replaces the component’s inline style rather than merging with it. Being a UA declaration, it also survivespointer-events: noneinherited from a HUD root, and still loses to anypointer-eventsyou declare on the viewport yourself (which is how you deliberately hand a region back to the UI).The inheritance trap is still real for everything you build yourself.
pointer-eventsis inherited, sopointer-events: noneon a HUD root — the natural way to let clicks fall through to the game — reaches every descendant. Your own HUD controls, and any hand-rolled hole that isn’t adata-viewportnode, need an explicit opt-in:#![allow(unused)] fn main() { div { style: "pointer-events: none;", // HUD root: clicks fall through GameViewport { name: "main", style: "flex: 1;" } // hittable by default div { style: "pointer-events: auto;", /* real HUD controls */ } } }That inheritance is spec-correct, not a bug — but the overlay idiom that triggers it is the obvious one to reach for, so it is worth designing around up front. (
visibility: hiddenis stronger still on desktop: it makes an entire subtree unhittable, and a descendant that setsvisibility: visibleis painted but still not hittable there — issue #843. A browser, and sorinch-web, lets that descendant take pointer events.)
Split Layout (Viewport Hole)
Use GameViewport to mark a transparent region where the game renders:
#![allow(unused)]
fn main() {
use rinch::embed::GameViewport;
#[component]
fn editor_ui() -> NodeHandle {
rsx! {
div { style: "display: flex; flex-direction: column; height: 100%;",
div { class: "toolbar",
Button { onclick: || save(), "Save" }
}
div { style: "display: flex; flex: 1;",
div { style: "width: 200px;", /* side panel */ }
GameViewport { name: "main", style: "flex: 1;" }
}
}
}
}
}
Query the viewport rect to set your game’s render region:
#![allow(unused)]
fn main() {
if let Some(rect) = ctx.viewport_rect("main") {
game.set_viewport(rect.x, rect.y, rect.width, rect.height);
}
}
A hole with nothing behind it:
data-viewport-ready. Cutting the hole removes the ancestors’ background —<body>’s included, since the UA sheet makes itoverflow-y: auto— so on a transparent window a hole nothing fills is see-through to the desktop, not to your app (#186). A viewport whose content can be absent opts out by stampingdata-viewport-ready="false"on the hole; paint then leaves the backgrounds alone and the node paints its ownbackground, which is the natural seat for a poster or an error affordance. Set it back to"true"once there is something to show.The attribute is an opt-out, and absence means ready: a
data-viewportnode that never stamps it punches unconditionally, exactly as before.GameViewportis therefore unaffected — it stamps nothing, keeps its hole from the first frame, and needs no change.VideoViewport(rinch-video) is the one built-in that opts in: it stays"false"until the decoder has handed a real frame to the compositor, and flips back to"false"on aPlaybackState::Error. A node that does carry the attribute must say exactly"true"to punch, so a mis-stamped value fails to the safe side — an opaque placeholder, never a see-through window.Not ready also means no frame, on both backends. A surface keeps the last buffer it was given, so a video that errors mid-stream still has one; while its node says
"false"that frame is not drawn — the software backend paints the node as an ordinary element, and the GPU backend hands the compositor no layer for it — and the node’s ownbackgroundis what shows. Say"true"again and the frame the surface holds is back, with no new frame needed.Since #358 software cuts no hole for a video at all (see below), so there the gate decides only whether the frame or the node’s
#000placeholder is drawn.
A hole bigger than what fills it: black bars (GPU). The complementary case (#354). The GPU compositor aspect-fits a frame inside its viewport, so a source whose aspect ratio differs from the box leaves letterbox or pillarbox bars — inside the punched hole, but covered by no layer. That was see-through to the desktop for a video playing perfectly. The compositor now pushes an opaque black layer over the whole viewport box under the frame’s own layer, so the bars come out black exactly as a browser paints them for
<video>, and the hole is always fully covered.The black is painted by the compositor, not by the viewport element. That distinction is the whole design: the compositor draws under the Vello UI, so giving the element an opaque
backgroundwould hide the video entirely.GameViewportis unaffected — its surface fills its box, so the fit is exact and the backdrop is entirely overdrawn.On the software backend the same bars are black for a different reason — see the next note.
Software paints every viewport inline. The two backends route viewport frames differently, and the split is worth knowing if you are reading the compositing code (#358, #361).
RenderSurfacevideo GameViewportsoftware inline inline inline GPU inline compositor + black backdrop compositor Software used to blit its compositor frames onto the finished pixel buffer, after the whole UI had been painted, clipped only by the viewport’s overflow-clipping ancestors. That write has no notion of occlusion, so every overlay above a playing video or a game — the nav drawer,
Modal,DropdownMenu,Select’s popup, tooltips, a HUD, DevTools — was overwritten. GPU has no such problem: its layers go down first and the Vello UI alpha-blends on top, so an opaque drawer already covers the layer there.So on software a frame goes through paint instead, on the same inline path a
RenderSurfacecomponent has always used: thedata-viewportnode draws the frameobject-fit: containinside its box, at its own z-order. Overlays occlude it by ordinary paint order, with no occlusion tracking anywhere, and a new frame’s damage is the viewport’s box — not a full repaint. A paint with no new frame (a hover elsewhere over a paused game) does not damage the viewport; it is repainted only where something else’s damage reaches it — a box moving across it, an overlay above it closing — and then from the frame it already holds.It is not slower than the blit was.
submit_framechecks, on the thread that submits, whether every pixel is opaque; an opaque frame is never premultiplied, and one that lands on whole pixels — at its natural size, or scaled to a box whose edges are whole pixels — is copied into the surface row by row, byte-identical to the sampled draw. Measured in release on one host (game_viewport_inline_bench), per frame on the UI thread, inline vs the old blit: a 1920×1080 frame in a 1920×1080 viewport 1.5 vs 4.8 ms, the same frame in a 960×540 viewport 2.3 vs 2.3 ms, a 320×180 frame in 640×360 0.7 vs 0.8 ms. A frame with any translucent pixel is premultiplied and sampled per draw, as any image is.What still tells video and
GameViewportapart is the hole:
- Video punches none: it is no compositor frame, so its name is absent from the active-viewport set and #186’s filter reads “absent ⇒ do not punch”. The node fills opaque black under the frame, and the
containfit plus that fill are #354’s letterbox bars on this backend.GameViewportkeeps its hole while it has a frame, as it always had: its ancestors’ backgrounds are cut away under it and no black is drawn, so where the frame does not reach — a letterbox, or pixels the game left transparent — is the hole, see-through on a transparent window.The two are told apart by an explicit flag set at video’s registration site, not by the viewport’s name — they share
create_render_surface_with_name, so a naming rule would treat the game viewport as video. A surface carrying a GPU texture source never takes the inline path: it has no CPU pixels to paint, and is the GPU compositor’s.
Overlays: which ancestor sizes them. A
position: absoluteoverlay is sized and placed against its containing block, as in a browser and underrinch-web: the padding box of its nearest positioned (or transformed) ancestor, however many unpositioned elements sit between them (#386), or the window when it has none (#204). Soposition: absolute; inset: 0insideposition: relativechrome fills the chrome even when the element directly enclosing it has auto height, and dropped into unpositioned containers it fills the window.Two things to know. An overlay meant to cover one panel needs that panel to be
position: relative, or it covers the next positioned ancestor up. And aposition: relativeinline element (a<span>) is a containing block too (#631): an overlay inside one resolves against the span’s text, not the block that holds the line. On one line that is the text’s box. For a span that wraps, the block runs from the first line’s start of the text to the last line’s end of it, soinset: 0is usually zero wide there (the last line ends left of where the first began), as in a browser. Make the wrapperdisplay: inline-block(or a block) when the overlay should cover a box.
Resize and Scale Factor
#![allow(unused)]
fn main() {
ctx.resize(new_width, new_height);
overlay.resize(&device, new_width, new_height);
ctx.set_scale_factor(window.scale_factor());
}
set_scale_factor also feeds Stylo’s device_pixel_ratio (issue #211): CSS
resolution media queries (@media (min-resolution: 2dppx)), image-set()
candidate selection, and border-width device-pixel snapping all follow the
value you pass. A changed scale restyles the document on the next update(),
which also re-lays it out at the new logical size. The initial value comes
from RinchContextConfig::scale_factor and is live from the first layout.
Hit testing is transform-aware, so the usual position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%) centering idiom is fully clickable —
a transformed subtree is hit where it is painted.
Caveat at HiDPI on the embed path. The embed path resolves layout at logical size, so
set_scale_factorgenuinely matters here — and paint currently applies a CSStranslatewithout multiplying it by that scale (#202). At any scale other than1.0, a translated element therefore paints slightly off from where it hit-tests. Onlytranslateis affected (rotate/scale/skeware unit-invariant), and only on the embed path. Centering with flexbox instead of atranslatesidesteps it entirely until that fix lands.
Platform Events
Translate your engine’s events to rinch_platform::PlatformEvent:
#![allow(unused)]
fn main() {
use rinch_platform::{PlatformEvent, MouseButton, KeyCode, KeyRepeat, Modifiers};
PlatformEvent::MouseMove { x: 100.0, y: 200.0 }
PlatformEvent::MouseDown { x: 100.0, y: 200.0, button: MouseButton::Left }
PlatformEvent::MouseUp { x: 100.0, y: 200.0, button: MouseButton::Left }
PlatformEvent::MouseWheel { x: 100.0, y: 200.0, delta_x: 0.0, delta_y: -30.0 }
PlatformEvent::KeyDown {
key: KeyCode::KeyA,
// The layout-produced `KeyboardEvent.key` value, case-accurate ("A" under
// Shift). `None` = unknown; the app then falls back to the physical `key`.
logical_key: Some("a".into()),
text: Some("a".into()),
modifiers: Modifiers::default(),
// Is this a fresh physical press, or the OS repeating a key still held
// (issue #463)? Say `KeyRepeat::Repeat` for a re-sent held key, and
// `KeyRepeat::Unknown` if your engine cannot tell — the runtime then
// infers it from the press/release pair, as it always did. Answering
// where you can is what keeps a swallowed release from stranding the
// Enter/Space activation of a focused node.
repeat: KeyRepeat::Fresh,
}
PlatformEvent::KeyUp {
key: KeyCode::KeyA,
// Spell it like the press, or a consumer pairing press with release by
// key string ("is A still held") never matches.
logical_key: Some("a".into()),
modifiers: Modifiers::default(),
}
PlatformEvent::Resized { width: 1920, height: 1080 }
}
Pointer coordinates are logical (CSS) pixels, not physical ones (issue #299).
MouseMove/MouseDown/MouseUp/MouseWheel,MouseWheel’sdelta_x/delta_y, and theFile*positions are all in the space the document is laid out in andhit_testprobes. A windowing system that reports physical pixels — winit does — must divide first, andrinch_platform::to_logical_point(point, scale)is the shared conversion:#![allow(unused)] fn main() { let (lx, ly) = rinch_platform::to_logical_point((position.x, position.y), window.scale_factor()); events.push(PlatformEvent::MouseMove { x: lx as f32, y: ly as f32 }); }Forwarding the physical position instead displaces every click, hover, drag and scroll by the scale factor times its distance from the window origin — it is invisible at scale 1.0 and wrong everywhere else. Note this is the opposite of
resize(w, h)andRinchContextConfig’swidth/height, which stay physical;scale_factoris how rinch reconciles the two.wants_mouse(x, y)takes the same logical pair, so ask it with exactly what you put on the event.examples/game-embeddoes this.
API Reference: Embed
RinchContext:
| Method | Description |
|---|---|
new(config, component) | Create and mount a rinch UI |
update(&events) -> Vec<AppAction> | Process events, update layout, return actions |
scene() -> &Scene | Get the Vello scene (lazy rebuild) |
resize(w, h) | Notify of window resize (physical pixels) |
set_scale_factor(scale) | Update DPI scale factor |
set_theme(&props) | Replace this context’s theme (restyles on the next update()) |
viewport_rect(name) -> Option<LayoutRect> | Query a GameViewport’s computed rect |
wants_mouse(x, y) -> bool | True if point hits UI (not viewport hole) |
wants_keyboard() -> bool | True for a text input, the editor, an open <select> popup, a generic node that registered on_key, or a keyboard-focused (Tab/NodeHandle::focus()) generic node — not a plain button/link a mouse click merely focused (issue #548; the click-vs-Tab split follows :focus-visible) |
needs_update() -> bool | True if UI needs repaint (including a due next_wake or a new RenderSurface frame) |
next_wake() -> Option<Instant> | When to call update() again with no input (the caret blink), or None |
register_font(data) | Register font data for text rendering |
app() / app_mut() | Access the underlying RinchApp |
RinchOverlayRenderer:
| Method | Description |
|---|---|
new(device, w, h, format) | Create from game’s wgpu device |
render(device, queue, scene) -> TextureView | Render scene to texture |
resize(device, w, h) | Resize render target |
texture() | Get the underlying wgpu Texture |
RinchContextConfig:
| Field | Type | Description |
|---|---|---|
width | u32 | Initial viewport width (physical pixels) |
height | u32 | Initial viewport height (physical pixels) |
scale_factor | f64 | Display scale factor |
theme | Option<ThemeProviderProps> | Theme configuration |
Theming is per-context. Each RinchContext owns the theme CSS generated
from its config.theme (or the default theme when None) — creating a second
context, or a shell app changing the thread-global theme, never restyles an
existing context (issue #138). To change an embedded context’s theme at
runtime, call ctx.set_theme(&props); the document restyles on its next
update().
Caveat: an embedded
ThemeProvidercomponent with a reactivedark_mode_fn/primary_color_fnwrites the thread-global theme slot, which embed contexts deliberately ignore —RinchContext::set_themeis the supported path for runtime theme changes in the embed API.
LayoutRect:
| Field | Type | Description |
|---|---|---|
x | f32 | Absolute X position (logical pixels) |
y | f32 | Absolute Y position (logical pixels) |
width | f32 | Width (logical pixels) |
height | f32 | Height (logical pixels) |
Which Pattern to Use?
| Scenario | Use |
|---|---|
| Adding UI overlay to an existing game engine | Embed API — game keeps its window/GPU ownership |
| Building a tool with embedded viewports (e.g., level editor, paint app) | RenderSurface — rinch handles the window, you embed content |
| Terminal emulator, video player, or custom canvas inside a rinch app | RenderSurface — component-level integration |
| WASM game with HTML-based UI | Neither — use the browser-native DOM backend |
Examples
examples/game-embed/— Spinning cube with rinch HUD overlay (embed API)examples/render-surface-demo/— Painting app with canvas and navigator (RenderSurface)examples/webgpu-surface-web/— (web) WebGPU triangle in a rinch DOM UI — the app owns a wgpu<canvas>viewport (RenderSurface onrinch-web)