(,
Tiny SPy/WASM demo.
Parameters
The SPy source code below is what drives the animation above. It is compiled to WebAssembly by the SPy compiler and runs directly in your browser — no Python runtime, no PyScript, no Pyodide.
"""
SPy particle simulation using the canvas 2D API directly.
Known missing vs JS version:
- math.random not yet available: particles initialized deterministically
Note that closures are not yet supported in red function so we have to use few global
variables.
However, module level code is quite particular in SPy:
- It is evaluated at import time (i.e. during compilation).
- Global variables are by default constant and a keyword `var` is used to indicate that they
are real variables.
- Moreover, currently `var` global variables can be only simple types and NULL pointers.
jsffi is also currently very limited. In particular, there is no equivalent to the
`new` JS keyword, so we need to rely on function helpers (like js_u8array_from_ptr
and js_new_ImageData).
"""
import math
import jsffi
from jsffi import JsRef
from unsafe import gc_alloc, gc_ptr
from time import time
WIDTH: i32 = 800
HEIGHT: i32 = 600
MAX_PARTICLES: i32 = 80
TWO_PI: f64 = 2.0 * math.pi
@struct
class Particle:
x: f64
y: f64
vx: f64
vy: f64
hue: f64
@struct
class Params:
n_particles: i32
speed: f64
radius: i32
var n_jsrefs: i32 = 0
var PARTICLES: gc_ptr[Particle] = gc_ptr[Particle].NULL
var PARAMS: gc_ptr[Params] = gc_ptr[Params].NULL
var JS_REFS: gc_ptr[JsRef] = gc_ptr[JsRef].NULL
CTX_IDX: i32 = 0
def get_ctx() -> JsRef:
return JS_REFS[CTX_IDX]
def sync_slider[id]() -> None:
document = jsffi.get_Document()
span = document.getElementById(id + "Val")
elem = document.getElementById(id)
value = elem.value
span.textContent = value
setattr(PARAMS[0], id, value)
# SPy has currently no destructor
jsffi.drop_ref(span)
jsffi.drop_ref(elem)
jsffi.drop_ref(value)
def draw_particle(p: Particle, radius: i32, ctx: JsRef) -> None:
r_f: f64 = float(radius)
hue_i: i32 = int(p.hue) % 360
hue_s = str(hue_i)
glow_color = "hsla(" + hue_s + ", 100%, 60%, 0.6)"
core_color = "hsl(" + hue_s + ", 100%, 75%)"
transparent = "hsla(" + hue_s + ", 100%, 50%, 0)"
grd = ctx.createRadialGradient(p.x, p.y, 0.0, p.x, p.y, r_f * 2.5)
grd.addColorStop(0.0, glow_color)
grd.addColorStop(1.0, transparent)
ctx.fillStyle = grd
ctx.beginPath()
ctx.arc(p.x, p.y, r_f * 2.5, 0.0, TWO_PI)
ctx.fill()
ctx.fillStyle = core_color
ctx.beginPath()
ctx.arc(p.x, p.y, r_f, 0.0, TWO_PI)
ctx.fill()
jsffi.drop_ref(grd)
def simulate_step(n: i32, speed: f64, radius: i32) -> None:
r_f: f64 = float(radius)
w_f: f64 = float(WIDTH)
h_f: f64 = float(HEIGHT)
ctx = get_ctx()
ctx.fillStyle = "rgba(15, 23, 42, 0.25)"
ctx.fillRect(0.0, 0.0, w_f, h_f)
for i in range(n):
p: Particle = PARTICLES[i]
mag: f64 = math.sqrt(p.vx * p.vx + p.vy * p.vy)
if mag > 0.0001:
vx = p.vx / mag * speed
vy = p.vy / mag * speed
p = Particle(p.x, p.y, vx, vy, p.hue)
x = p.x + p.vx
y = p.y + p.vy
vx = p.vx
vy = p.vy
if x - r_f < 0.0:
x = r_f
vx = -p.vx
if x + r_f > w_f:
x = w_f - r_f
vx = -vx
if y - r_f < 0.0:
y = r_f
vy = -vy
if y + r_f > h_f:
y = h_f - r_f
vy = -vy
hue = (p.hue + 0.4) % 360.0
p = Particle(x, y, vx, vy, hue)
draw_particle(p, radius, ctx)
PARTICLES[i] = p
def frame(timestamp: f64) -> None:
console = jsffi.get_Console()
params = PARAMS[0]
t_start = time()
simulate_step(params.n_particles, params.speed, params.radius)
console.log("simulate_step done in", time() - t_start, "s")
n_tmp = jsffi._debug_n_jsrefs()
n_per_frame = n_tmp - n_jsrefs
n_jsrefs = n_tmp
console.log("n_per_frame:", n_per_frame, "; per part: ", n_per_frame / params.n_particles)
jsffi.request_animation_frame(frame)
# this is equivalent to
# jsffi.drop_ref(jsffi.get_GlobalThis().requestAnimationFrame(frame))
def init_particles(speed: f64, radius: i32) -> None:
"""Deterministic sunburst init — replace with random angles once math.random exists."""
cx: f64 = float(WIDTH) / 2.0
cy: f64 = float(HEIGHT) / 2.0
for i in range(MAX_PARTICLES):
angle: f64 = float(i) * TWO_PI / MAX_PARTICLES
spd: f64 = speed * (0.5 + float(i % 5) * 0.1)
PARTICLES[i].x = cx
PARTICLES[i].y = cy
PARTICLES[i].vx = math.cos(angle) * spd
PARTICLES[i].vy = math.sin(angle) * spd
PARTICLES[i].hue = float(i * 360 // MAX_PARTICLES)
def main() -> None:
jsffi.init()
PARTICLES = gc_alloc[Particle](MAX_PARTICLES)
PARAMS = gc_alloc[Params](1)
JS_REFS = gc_alloc[JsRef](1)
document = jsffi.get_Document()
canvas = document.getElementById("canvas")
ctx = canvas.getContext("2d")
JS_REFS[CTX_IDX] = ctx
window = jsffi.get_GlobalThis()
document = jsffi.get_Document()
PARAMS[0].n_particles = 20
PARAMS[0].speed = 3.0
PARAMS[0].radius = 6
# will use: for color in unroll(["n_particles", "speed", "radius"]):
elem = document.getElementById("n_particles")
PARAMS[0].n_particles = elem.value
elem.addEventListener("input", sync_slider["n_particles"])
elem = document.getElementById("speed")
PARAMS[0].speed = elem.value
elem.addEventListener("input", sync_slider["speed"])
elem = document.getElementById("radius")
PARAMS[0].radius = elem.value
elem.addEventListener("input", sync_slider["radius"])
init_particles(PARAMS[0].speed, PARAMS[0].radius)
window.requestAnimationFrame(frame)