Yo Odin ยท Chapter 5

Chapter 5: The renderer, part 1: the command buffer and the bridge

This chapter: build the core of the batch library: a packed command buffer, its wire format and encoders, and the JavaScript that replays a whole frame onto the canvas in one crossing.

Where we left off: we can cross the WASM boundary and serve the result over HTTP.


5.1 Why batch at all

The spike called JS once per rectangle. A real frame of Destroids draws well over a thousand shapes: hundreds of stars, dozens of asteroids, bullets, particles. One foreign call per shape means a thousand-plus boundary crossings every frame, sixty times a second. That boundary is the bottleneck, not the drawing.

Why batching wins. Record the whole frame into one byte buffer in Odin, then cross once to replay it. A thousand draw ops become a thousand array writes (cheap) plus one foreign call (the only crossing). The JS side becomes a mechanical interpreter of our bytes.

5.2 The wire format

We encode each draw op as a one-byte opcode followed by its typed arguments, little-endian:

command := opcode:u8  then that opcode's args
f32 arg := 4 bytes IEEE-754 LE
u16 arg := 2 bytes LE
str arg := len:u16 LE  then len UTF-8 bytes
u8  arg := 1 byte

That's the entire contract between Odin and JS. Both ends must agree on it exactly: the opcode constants in Odin and the switch in JS are two halves of one format.

5.3 The buffer

A fixed-capacity arena, reused every frame, with no per-frame allocation:

// batch/batch.odin
package batch

CAPACITY :: 1 << 20   // 1 MiB, comfortably fits a full frame

Buffer :: struct {
	data:       [CAPACITY]u8,
	len:        int,
	overflowed: bool,
}

reset :: proc "contextless" (b: ^Buffer) {   // call at the start of each frame
	b.len = 0
	b.overflowed = false
}

The lifecycle is always: reset โ†’ record ops โ†’ flush.

5.4 The encoders

Private helpers append raw values, each guarding against overflow:

@(private)
put_u8 :: proc "contextless" (b: ^Buffer, v: u8) {
	if b.len + 1 > CAPACITY { b.overflowed = true; return }
	b.data[b.len] = v
	b.len += 1
}

@(private)
put_f32 :: proc "contextless" (b: ^Buffer, v: f32) {
	if b.len + 4 > CAPACITY { b.overflowed = true; return }
	bits := transmute(u32)v            // reinterpret the float's bits
	b.data[b.len + 0] = u8(bits)
	b.data[b.len + 1] = u8(bits >> 8)
	b.data[b.len + 2] = u8(bits >> 16)
	b.data[b.len + 3] = u8(bits >> 24)
	b.len += 4
}

put_u16 and put_str follow the same shape (put_str writes a u16 length then the bytes). See Appendix B for the two you're not shown here.

5.5 The opcodes and their public procedures

Each opcode is a constant; each has a one-line public encoder. Here's a representative slice; the full set (state, rects, paths, text, transforms, shadows) is in batch/batch.odin:

OP_SET_FILL   :: 0x01   // str
OP_FILL_RECT  :: 0x10   // f32 x4
OP_BEGIN_PATH :: 0x20
OP_MOVE_TO    :: 0x21   // f32 x2
OP_LINE_TO    :: 0x22   // f32 x2
OP_ARC        :: 0x24   // x,y,r,a0,a1 (f32) + ccw (u8)
OP_STROKE     :: 0x29

set_fill :: proc "contextless" (b: ^Buffer, color: string) {
	put_u8(b, OP_SET_FILL); put_str(b, color)
}
fill_rect :: proc "contextless" (b: ^Buffer, x, y, w, h: f32) {
	put_u8(b, OP_FILL_RECT); put_f32(b, x); put_f32(b, y); put_f32(b, w); put_f32(b, h)
}
begin_path :: proc "contextless" (b: ^Buffer) { put_u8(b, OP_BEGIN_PATH) }
move_to :: proc "contextless" (b: ^Buffer, x, y: f32) {
	put_u8(b, OP_MOVE_TO); put_f32(b, x); put_f32(b, y)
}
line_to :: proc "contextless" (b: ^Buffer, x, y: f32) {
	put_u8(b, OP_LINE_TO); put_f32(b, x); put_f32(b, y)
}
arc :: proc "contextless" (b: ^Buffer, x, y, r, a0, a1: f32, ccw := false) {
	put_u8(b, OP_ARC)
	put_f32(b, x); put_f32(b, y); put_f32(b, r); put_f32(b, a0); put_f32(b, a1)
	put_u8(b, ccw ? 1 : 0)
}
stroke :: proc "contextless" (b: ^Buffer) { put_u8(b, OP_STROKE) }

Note they're all proc "contextless": pure encoding, no context needed.

5.6 The single crossing

One foreign function carries the whole buffer over:

foreign import batch_env "batch_env"

@(default_calling_convention = "contextless")
foreign batch_env {
	batch_cmd_flush :: proc(ctx: Handle, buf: []byte) ---
}

flush :: proc "contextless" (ctx: Handle, b: ^Buffer) {
	if b.len == 0 { return }
	batch_cmd_flush(ctx, b.data[:b.len])
}

5.7 The JavaScript interpreter

On the JS side, batch_cmd_flush walks the bytes and replays each op onto the 2D context. This is the other half of the wire format: every case here mirrors an opcode constant above:

// web/batch.js: inside the batch_env module
batch_cmd_flush: (ctx, bufPtr, bufLen) => {
  const buf   = wasmMemory.buffer;
  const bytes = new Uint8Array(buf, bufPtr, bufLen);
  const view  = new DataView(buf, bufPtr, bufLen);
  let p = 0;
  const f   = () => { const v = view.getFloat32(p, true); p += 4; return v; };
  const u8  = () => bytes[p++];
  const str = () => {
    const n = view.getUint16(p, true); p += 2;
    const s = new TextDecoder().decode(new Uint8Array(buf, bufPtr + p, n)); p += n;
    return s;
  };
  const c = jsValues[ctx];
  while (p < bufLen) {
    switch (bytes[p++]) {
      case 0x01: c.fillStyle = str(); break;
      case 0x10: c.fillRect(f(), f(), f(), f()); break;
      case 0x20: c.beginPath(); break;
      case 0x21: c.moveTo(f(), f()); break;
      case 0x22: c.lineTo(f(), f()); break;
      case 0x24: { const x=f(),y=f(),r=f(),a0=f(),a1=f(); c.arc(x,y,r,a0,a1,u8()!==0); break; }
      case 0x29: c.stroke(); break;
      // ...one case per opcode; see Appendix B for the full switch
      default: return;   // unknown opcode, bail rather than desync
    }
  }
},

Gotcha. The Odin opcode constants and this JS switch are one format in two files. Add or change an op on one side and you must change the other, or the interpreter reads an argument as an opcode and the whole buffer desyncs. When you extend the renderer, edit both ends together.

The default: return is a small safety net: on an unknown byte we stop rather than misread the rest of the buffer.

5.8 The runtime hooks

web/batch.js also supplies odin_env (the same runtime hooks the spike used: output, trap, the float-math intrinsics, rand_bytes) and the handle table (jsValues) that ctx indexes into. Those are unchanged from Chapter 3; the full file is in Appendix B. We finish the library, the canvas, the loop, and input, in the next chapter.

5.9 A first batched picture

You can already record and replay a static scene: reset a buffer, set_fill, a few fill_rects and a stroked path, then flush. It draws in a single crossing. We can't run it standalone until the canvas bootstrap exists (next chapter), but the encoding half is done and, being pure byte manipulation, is the easiest part of the whole system to reason about.

Checkpoint

You have a 1 MiB reusable command buffer, little-endian encoders, a public draw-op API, a single-crossing flush, and a JS interpreter that replays the buffer onto a canvas context. The wire format is defined and mirrored on both sides.

Exercises

  1. Add OP_STROKE_RECT (0x11) end to end: constant, stroke_rect encoder, JS case. Confirm the two ends agree.
  2. Add an overflow log: when b.overflowed is set, batch.log a warning once per frame.
  3. Sketch on paper the exact bytes produced by set_fill(b, "#fff") followed by fill_rect(b, 0, 0, 2, 2). Check the lengths against the wire format.

Further reading