Yo Odin ยท Chapter 6

Chapter 6: The renderer, part 2: canvas, the loop, and input

This chapter: finish the batch library: create a canvas, drive an animation loop through one dispatcher, and read the keyboard, then prove it with a movable square. Meet the scratch-buffer gotcha.

Where we left off: the command buffer encodes and flushes, but nothing creates a canvas or runs a frame yet.


6.1 The Handle type

Recall the handle-table idea: every browser object is an integer on the JS side. We give it a distinct type so it can't be mixed up with any other u32:

// batch/canvas.odin
Handle :: distinct u32

INVALID :: Handle(0)

is_valid :: proc "contextless" (h: Handle) -> bool { return h != INVALID }

Index 0 is always null.

6.2 Canvas bootstrap

Four thin wrappers over JS-provided functions get us a canvas and its 2D context:

foreign import batch_env "batch_env"

@(default_calling_convention = "contextless")
foreign batch_env {
	batch_get_element_by_id  :: proc(id: string) -> Handle ---
	batch_canvas_create      :: proc(parent: Handle, width, height: u32) -> Handle ---
	batch_canvas_get_context :: proc(canvas: Handle) -> Handle ---
	// ...more below
}

get_element_by_id :: proc "contextless" (id: string) -> Handle {
	return batch_get_element_by_id(id)
}
canvas_create :: proc "contextless" (parent: Handle, width, height: int) -> Handle {
	return batch_canvas_create(parent, u32(width), u32(height))
}
canvas_get_context :: proc "contextless" (canvas: Handle) -> Handle {
	return batch_canvas_get_context(canvas)
}

On the JS side these create a real <canvas> and hand back handles:

batch_get_element_by_id: (ptr, len) => {
  const el = document.getElementById(getStr(ptr, len));
  return el ? getHandle(el) : 0;
},
batch_canvas_create: (parent, width, height) => {
  const canvas = document.createElement("canvas");
  canvas.width = width; canvas.height = height;
  jsValues[parent].appendChild(canvas);
  return getHandle(canvas);
},
batch_canvas_get_context: (canvas) => getHandle(jsValues[canvas].getContext("2d")),

6.3 One dispatcher for every callback

The browser's animation loop and every event funnel through a single exported procedure, keyed by an integer id the app chooses. The library asks JS to start the loop; JS calls back:

foreign batch_env {
	batch_start_animation_loop :: proc(cb_id: u32) ---
	batch_now                  :: proc() -> f64 ---
}

start_animation_loop :: proc "contextless" (cb_id: u32) { batch_start_animation_loop(cb_id) }
now :: proc "contextless" () -> f64 { return batch_now() }   // performance.now()
batch_start_animation_loop: (cbId) => {
  const tick = () => {
    wasmExports.batch_invoke_callback(cbId);   // JS -> Odin, once per frame
    requestAnimationFrame(tick);
  };
  requestAnimationFrame(tick);
},

The host app exports batch_invoke_callback(id: u32) and switches on the id. One export, any number of callbacks.

Why one dispatcher? WASM exports are a flat list of named functions; you can't hand JS an arbitrary Odin closure. Routing everything through one exported proc keyed by a small integer is the clean way to register many callbacks with one export. Adding an event later means adding a CB_* constant and a case, never a new export.

6.4 Events and the raw event object

To read the keyboard we listen on the window (keys aren't delivered to an unfocused canvas) and read fields off the raw JS Event:

foreign batch_env {
	batch_get_global          :: proc(name: string) -> Handle ---
	batch_add_event_listener  :: proc(elem: Handle, event: string, cb_id: u32) ---
	batch_get_property_str    :: proc(h: Handle, key: string, buf: []byte) -> int ---
	batch_call_method0        :: proc(h: Handle, name: string) ---
}

get_window :: proc "contextless" () -> Handle { return batch_get_global("window") }
add_event_listener :: proc "contextless" (elem: Handle, event: string, cb_id: u32) {
	batch_add_event_listener(elem, event, cb_id)
}
call_method :: proc "contextless" (h: Handle, name: string) { batch_call_method0(h, name) }

JS stashes the event object in a reserved handle slot and tells Odin about it right before dispatching:

batch_add_event_listener: (elem, eventPtr, eventLen, cbId) => {
  const event = getStr(eventPtr, eventLen);
  jsValues[elem].addEventListener(event, (evt) => {
    jsValues[EVENT_HANDLE] = evt;
    wasmExports.batch_set_last_event(EVENT_HANDLE);
    wasmExports.batch_invoke_callback(cbId);
  });
},

Odin exposes the current event and a way to read its string fields:

@(private) last_event: Handle = INVALID
last_event_handle :: proc "contextless" () -> Handle { return last_event }

@(export)
batch_set_last_event :: proc "contextless" (h: Handle) { last_event = h }

get :: proc "contextless" (h: Handle, key: string) -> string {
	n := batch_get_property_str(h, key, scratch[:])
	if n > len(scratch) { n = len(scratch) }
	return string(scratch[:n])
}

6.5 The scratch-buffer gotcha

get returns a string that points straight into one shared global buffer:

@(private) scratch: [4096]u8

That string is valid only until the next call that touches scratch, i.e. the next get.

Gotcha. This has bitten real code more than once. Read a key's code, then call get again (or anything that writes scratch) before using code, and code is silently overwritten:

code := batch.get(ev, "code")     // points at scratch
kind := batch.get(ev, "type")     // OVERWRITES scratch, code is now garbage
// ...using code here is a bug

The fix is to finish with one string before fetching the next, or copy it into a local buffer. In the demo below we compute the boolean from "type" first, then fetch "code", so each string is consumed before the next get.

6.6 The loader

Batch.instantiate builds the import object (the odin_env runtime hooks + the batch_env bridge), merges any app-specific modules you pass, instantiates the wasm, and calls batch_main:

Batch.instantiate: async function (wasmUrl, extraImports) {
  // ...build importObject with odin_env + batch_env...
  if (extraImports) {
    for (const mod of Object.keys(extraImports))
      importObject[mod] = { ...(importObject[mod] || {}), ...extraImports[mod] };
  }
  const wasm = await WebAssembly.instantiateStreaming(fetch(wasmUrl), importObject);
  wasmExports = wasm.instance.exports;
  wasmMemory  = wasmExports.memory;
  wasmExports.batch_main();          // JS -> Odin: run once
  return wasmExports;
},

The extraImports merge is how the game will hand in its own app_env module (sound, high score) in Chapter 7. The full web/batch.js is in Appendix B.

6.7 A movable square

Everything's in place for a tiny interactive app: a green square you steer with the arrow keys:

package main

import "base:runtime"
import batch "../batch"

CB_TICK :: 0
CB_KEY  :: 1

ctx: batch.Handle
cmd: batch.Buffer
px, py: f32 = 240, 160
move_l, move_r, move_u, move_d: bool

@(export)
batch_main :: proc "c" () {
	context = runtime.default_context()
	canvas := batch.canvas_create(batch.get_element_by_id("app"), 480, 320)
	ctx = batch.canvas_get_context(canvas)
	win := batch.get_window()
	batch.add_event_listener(win, "keydown", CB_KEY)
	batch.add_event_listener(win, "keyup", CB_KEY)
	batch.start_animation_loop(CB_TICK)
}

@(export)
batch_invoke_callback :: proc "c" (id: u32) {
	context = runtime.default_context()
	switch id {
	case CB_TICK:
		if move_l { px -= 3 }
		if move_r { px += 3 }
		if move_u { py -= 3 }
		if move_d { py += 3 }
		batch.reset(&cmd)
		batch.set_fill(&cmd, "#05070d"); batch.fill_rect(&cmd, 0, 0, 480, 320)
		batch.set_fill(&cmd, "#7fe0a0"); batch.fill_rect(&cmd, px - 8, py - 8, 16, 16)
		batch.flush(ctx, &cmd)
	case CB_KEY:
		ev := batch.last_event_handle()
		pressed := batch.get(ev, "type") == "keydown"   // consume "type" first...
		code := batch.get(ev, "code")                   // ...then fetch "code"
		switch code {
		case "ArrowLeft":  move_l = pressed
		case "ArrowRight": move_r = pressed
		case "ArrowUp":    move_u = pressed
		case "ArrowDown":  move_d = pressed
		}
	}
}

Build it into dist/, point an index.html with <div id="app"></div> and Batch.instantiate("demo.wasm") at it, and ./run.sh. Arrow keys move the square, drawn through your own renderer at 60 fps.

Checkpoint

The batch library is complete: canvas creation, a single-dispatcher animation loop, event listeners, the event-field bridge, and the scratch-buffer rule. You've driven a keyboard-controlled, batched, 60 fps demo. From here on, it's the game.

Exercises

  1. Add a dblclick listener (CB_CLICK) that recentres the square.
  2. Make the square wrap around the canvas edges instead of sliding off.
  3. Reproduce the scratch bug on purpose: fetch "code" then "type" and use code after; watch it break, then fix the ordering.

Further reading