///|
/// Headless frame rendering: run a game's `update` / `draw` for N ticks and
/// rasterize the last frame on the CPU.
///
/// This is the browser-free, GPU-free capture path. `@raster.RasterTarget` is
/// an ordinary `@gfx.GraphicsDriver`, so the game sees exactly the command
/// stream it would send to WebGPU and no example needs a second code path.
///
/// Target-agnostic on purpose: the JS entry point (`run_js.mbt`) and the
/// native capture can both drive it.
///|
/// One synthesized input tick. Cursor position and held keys are enough to
/// reach the states a UI actually breaks in -- hover, focus, a held direction
/// -- without pretending to be a full input recording.
pub(all) struct HeadlessInput {
cursor_x : Double
cursor_y : Double
pressed_keys : Array[Int]
}
///|
pub fn default_headless_input() -> HeadlessInput {
{ cursor_x: -1.0, cursor_y: -1.0, pressed_keys: [], }
}
///|
/// A source image the frame needs, as RGBA8. `id` matches the values a draw
/// command carries in `src_image_ids`.
///
/// These do not come from the graphics driver: `@gfx.GraphicsDriver` has no
/// upload call, because the real backend takes pixels straight from the runtime
/// hooks to the GPU. Without them a textured quad samples the unbound white
/// texture, which is why a sprite used to render as a flat white rectangle.
pub(all) struct HeadlessTexture {
id : Int
width : Int
height : Int
/// RGBA8, `width * height * 4` entries, each 0-255.
pixels : Array[Int]
}
///|
/// A rendered frame plus what the rasterizer had to say about the command
/// stream that produced it.
pub(all) struct HeadlessFrame {
width : Int
height : Int
/// How many update ticks ran before the captured draw.
frames : Int
/// RGBA8, `width * height * 4` entries.
pixels : Array[Int]
/// Draw commands the CPU rasterizer declined (3D geometry). A non-zero
/// count means the PNG is missing part of the scene, so callers must
/// surface it rather than treat the frame as complete.
skipped_commands : Int
drawn_triangles : Int
/// How many commands the draw callback produced, skipped ones included.
///
/// Reported separately from `drawn_triangles` because the two move
/// independently: batching a text run collapses ten commands into one
/// without changing a single triangle, and a renderer that starts emitting a
/// command per quad again would show up here and nowhere else.
draw_commands : Int
}
///|
/// Drive `update` for `frames` ticks, then rasterize one `draw` into an RGBA
/// buffer. `frames` of 0 draws the initial state without ever updating.
/// input_at supplies a complete snapshot for each zero-based tick, overriding
/// the held input. It can replay keyboard, pointer and gamepad transitions.
pub fn render_headless_frame(
update~ : (@core.InputSnapshot) -> Unit,
draw~ : (EngineContext) -> Array[@gfx.DrawTrianglesCommand],
width~ : Int,
height~ : Int,
frames~ : Int,
input? : HeadlessInput,
input_at? : (Int) -> @core.InputSnapshot,
on_frame? : () -> Unit,
textures? : Array[HeadlessTexture],
textures_at? : () -> Array[HeadlessTexture],
clear_color? : @gfx.Color,
) -> HeadlessFrame {
let target = @raster.RasterTarget::new(width, height)
match textures {
Some(list) =>
for texture in list {
target.register_texture(
texture.id,
texture.width,
texture.height,
texture.pixels,
)
}
None => ()
}
let ctx : EngineContext = {
dst: @gfx.new_image_handle(1, width, height),
shader: @gfx.new_shader_handle(1, @gfx.default_builtin_shader_source()),
screen_w: width,
screen_h: height,
}
let snapshot = headless_input_snapshot(
match input {
Some(value) => value
None => default_headless_input()
},
)
for tick in 0.. read(tick)
None => snapshot
},
)
match on_frame {
Some(f) => f()
None => ()
}
}
let cmds = draw(ctx)
if textures_at is Some(read) {
for texture in read() {
target.register_texture(
texture.id,
texture.width,
texture.height,
texture.pixels,
)
}
}
let color = match clear_color {
Some(c) => c
None => @gfx.new_color(0.0, 0.0, 0.0, 1.0)
}
target.clear(color)
for cmd in cmds {
let _ = target.draw_command(cmd)
}
{
width: target.width,
height: target.height,
frames,
pixels: target.pixels,
skipped_commands: target.skipped_commands,
drawn_triangles: target.drawn_triangles,
draw_commands: cmds.length(),
}
}
///|
fn headless_input_snapshot(input : HeadlessInput) -> @core.InputSnapshot {
@core.new_input_snapshot(
input.cursor_x,
input.cursor_y,
0.0,
0.0,
input.pressed_keys,
)
}
///|
/// Parse the comma-separated key-code list the headless request carries
/// (`"9,32"`). Anything that is not an integer is skipped rather than
/// aborting the render -- a malformed key list should not cost the frame.
pub fn parse_headless_keys(text : String) -> Array[Int] {
let keys : Array[Int] = []
let mut value = 0
let mut digits = 0
let mut negative = false
fn flush() -> Unit {
if digits > 0 {
keys.push(if negative { -value } else { value })
}
value = 0
digits = 0
negative = false
}
for ch in text {
let code = ch.to_int()
if code >= 48 && code <= 57 {
value = value * 10 + (code - 48)
digits = digits + 1
} else if code == 45 && digits == 0 {
negative = true
} else if code == 44 || code == 32 {
flush()
} else {
// Unparseable character: drop the token being built.
value = 0
digits = 0
negative = false
}
}
flush()
keys
}
///|
/// PNG bytes for a rendered frame, or `None` when the encoder rejects the
/// buffer.
pub fn headless_frame_png(frame : HeadlessFrame) -> Bytes? {
@capture.encode_rgba8_png(frame.width, frame.height, frame.pixels)
}
///|
let headless_texture_provider : Ref[() -> Array[HeadlessTexture]] = Ref(() => [])
///|
/// Runtime adapters provide the current CPU copies, including updates made this tick.
pub fn set_headless_texture_provider(
provider : () -> Array[HeadlessTexture],
) -> Unit {
headless_texture_provider.val = provider
}