Files
sx/library/modules/gpu/gles3.sx
agra 72593db953 mem: List(T) mutations gain optional alloc: Allocator = context.allocator
The chess panel-text regression (text vanished after the first move on
macOS) had a single root cause: GlyphCache's entries List, hash table,
and shaped_buf grew through `context.allocator` — which during render
is the per-frame arena. On the next arena reset the backing died, and
subsequent glyph lookups read garbage / wrote into freshly-allocated
view-tree memory.

Fix is shaped as the user proposed: `List(T)`'s mutations take an
optional trailing `alloc: Allocator = context.allocator` argument. No
allocator stored on the container, no init ceremony, every existing
`list.append(item)` callsite keeps working unchanged. Long-lived
owners now write `list.append(item, self.parent_allocator)` and the
arena-leak bug becomes impossible to write accidentally.

Default-arg substitution previously only fired for identifier callees
(`expandCallDefaults` at lower.zig:7978). Extended to the generic
struct-method dispatch path (`list.append(...)` lands here) via a new
`appendDefaultArgs` helper that lowers fd.params[i].default_expr in
the caller's scope and appends to the lowered args slice.

Long-lived owners updated to capture `parent_allocator: Allocator` at
init and use it for every internal growth:

- GlyphCache (the chess bug) — entries, shaped_buf, hash_keys,
  hash_vals, atlas bitmap.
- DockInteraction — drops the existing `push Context` workaround in
  `ensure_capacity` for the explicit-arg form.
- StateStore — entries list + per-entry data buffer.
- Gles3Gpu, MetalGPU — shaders, buffers, textures (atlas-grow during
  render would otherwise leak resources into the frame arena).

Also kept: an operator-precedence fix in pipeline.sx
(`(self.frame_index & 1) == 0` instead of
`self.frame_index & 1 == 0`, which parses as
`self.frame_index & (1 == 0)` = always 0). That was a stealth
single-arena-only bug that masked the GlyphCache one for a long time.

Docs:
- specs.md §11 documents `param: T = expr` default parameter values.
  The parser already supported it — formalised in the spec now.
- current/CHECKPOINT-MEM.md logs the change.
- CLAUDE.md REJECTED PATTERNS gains a "Long-lived containers growing
  through context.allocator" section with the `parent_allocator`
  capture template and the list of existing examples to mirror.

155/155 example tests pass — zero-diff against snapshots since every
existing callsite still resolves to `context.allocator`.
2026-05-25 14:41:17 +03:00

337 lines
14 KiB
Plaintext

// GLES3 backend for the GPU protocol. Android today.
//
// We use opengl.sx's runtime-loaded function-pointer variables for every
// GL call, populated via `load_gl(@eglGetProcAddress)` in `init()`.
// `#foreign` decls for the same names would collide with opengl.sx's
// global variables and silently win — that produces NULL function-
// pointer calls (PC=0 crash) because the variables are uninitialized.
//
// Conventions:
// - One shared VAO with the renderer's fixed 12-float layout
// (pos2 / uv2 / color4 / params4 = 48 bytes, attribute locations 0-3).
// `set_vertex_buffer` rebinds the VBO against this VAO each time.
// - `set_vertex_constants(slot=1, data, 64)` is treated as the 4x4
// projection matrix and uploaded via `glUniformMatrix4fv(uProj)`,
// matching the renderer.sx shader contract. Other slots are no-ops
// for now.
// - `set_texture(slot=0, tex)` binds texture unit 0 and sets
// `uniform sampler2D uTex` to 0. Other slots are no-ops.
// - The shader sources passed to `create_shader(vsrc, fsrc)` must be
// a GLES vertex + fragment pair (NOT the Metal MSL combined source
// the Metal backend takes — caller branches on OS).
#import "modules/std.sx";
#import "modules/compiler.sx";
#import "modules/opengl.sx";
#import "modules/gpu/types.sx";
#import "modules/gpu/api.sx";
// EGL bootstrap helper — used to populate opengl.sx's fn pointers once
// the EGL context is current. We declare only the loader here; EGL
// surface/context creation belongs in the consumer's `#jni_main`
// Activity (e.g. an `onCreate` body that calls `eglCreateContext`).
eglGetProcAddress :: (name: [*]u8) -> *void #foreign;
// Functions absent from opengl.sx (it was authored against the SDL
// desktop subset). We don't currently call these — destroy_* is best
// effort here; future work can wire `glDeleteProgram` etc. through
// load_gl if needed.
// ── State + resource tables ────────────────────────────────────────────
Gles3TextureSlot :: struct {
tex: u32 = 0;
bytes_per_pixel: u32 = 0;
}
Gles3ShaderSlot :: struct {
program: u32 = 0;
proj_loc: s32 = 0 - 1;
tex_loc: s32 = 0 - 1;
}
Gles3Gpu :: struct {
pixel_w: s32 = 0;
pixel_h: s32 = 0;
// The renderer's vertex layout is fixed (see file header). One VAO,
// reused across every set_vertex_buffer.
vao: u32 = 0;
current_shader: u32 = 0;
gl_loaded: bool = false;
shaders: List(Gles3ShaderSlot) = .{};
buffers: List(u32) = .{};
textures: List(Gles3TextureSlot) = .{};
// Captured at init() time so resource creation always grows the cache
// lists through the long-lived allocator, even when a caller (e.g.
// glyph_cache atlas-grow during render) is currently inside a transient
// arena context.
parent_allocator: Allocator = .{};
}
// ── GPU impl ───────────────────────────────────────────────────────────
impl GPU for Gles3Gpu {
// EGL is owned by AndroidPlatform; init() here populates the GL
// function pointers (via opengl.sx's load_gl) and sets up the
// single shared VAO. Must be called once the EGL context is
// current (AndroidPlatform.run_frame_loop ensures this before
// invoking the user's per-frame closure). `target` is unused.
init :: (self: *Gles3Gpu, target: *void, pixel_w: s32, pixel_h: s32) -> bool {
inline if OS != .android { return false; }
self.pixel_w = pixel_w;
self.pixel_h = pixel_h;
self.parent_allocator = context.allocator;
if !self.gl_loaded {
load_gl(@eglGetProcAddress);
self.gl_loaded = true;
}
// Create the VAO. Attribute layout is deferred to set_vertex_buffer
// because `glVertexAttribPointer` captures the currently-bound
// GL_ARRAY_BUFFER into the VAO state — we don't know which VBO
// the renderer wants until it tells us via set_vertex_buffer.
glGenVertexArrays(1, @self.vao);
glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
true;
}
shutdown :: (self: *Gles3Gpu) {
// GLES objects clean up when the EGL context is destroyed (which
// happens when the NativeActivity tears down).
}
resize :: (self: *Gles3Gpu, pixel_w: s32, pixel_h: s32) {
self.pixel_w = pixel_w;
self.pixel_h = pixel_h;
}
// The render-target setup (eglMakeCurrent + glViewport) lives in
// AndroidPlatform.run_frame_loop; begin_frame here just clears the
// active framebuffer. Returns true so callers don't bail out.
begin_frame :: (self: *Gles3Gpu, clear: ClearColor) -> bool {
inline if OS != .android { return false; }
glViewport(0, 0, self.pixel_w, self.pixel_h);
glClearColor(clear.r, clear.g, clear.b, clear.a);
glClear(GL_COLOR_BUFFER_BIT);
true;
}
// eglSwapBuffers happens in AndroidPlatform.end_frame; nothing for us.
end_frame :: (self: *Gles3Gpu, target_time: f64) { }
// ── Resources ────────────────────────────────────────────────────────
create_shader :: (self: *Gles3Gpu, vsrc: string, fsrc: string) -> ShaderHandle {
inline if OS != .android { return 0; }
prog := create_program(xx vsrc, xx fsrc);
if prog == 0 { return 0; }
slot : Gles3ShaderSlot = .{
program = prog,
proj_loc = glGetUniformLocation(prog, "uProj".ptr),
tex_loc = glGetUniformLocation(prog, "uTex".ptr),
};
self.shaders.append(slot, self.parent_allocator);
xx self.shaders.len;
}
create_buffer :: (self: *Gles3Gpu, size_bytes: s64) -> BufferHandle {
inline if OS != .android { return 0; }
if size_bytes <= 0 { return 0; }
b : u32 = 0;
glGenBuffers(1, @b);
glBindBuffer(GL_ARRAY_BUFFER, b);
glBufferData(GL_ARRAY_BUFFER, xx size_bytes, null, GL_DYNAMIC_DRAW);
self.buffers.append(b, self.parent_allocator);
xx self.buffers.len;
}
update_buffer :: (self: *Gles3Gpu, handle: BufferHandle, data: *void, size_bytes: s64) {
inline if OS != .android { return; }
buf := gles3_lookup_buffer(self, handle);
if buf == 0 { return; }
if data == null { return; }
if size_bytes <= 0 { return; }
glBindBuffer(GL_ARRAY_BUFFER, buf);
glBufferSubData(GL_ARRAY_BUFFER, 0, xx size_bytes, data);
}
update_buffer_at :: (self: *Gles3Gpu, handle: BufferHandle, data: *void, size_bytes: s64, byte_offset: s64) {
inline if OS != .android { return; }
buf := gles3_lookup_buffer(self, handle);
if buf == 0 { return; }
if data == null { return; }
if size_bytes <= 0 { return; }
if byte_offset < 0 { return; }
glBindBuffer(GL_ARRAY_BUFFER, buf);
glBufferSubData(GL_ARRAY_BUFFER, xx byte_offset, xx size_bytes, data);
}
create_texture :: (self: *Gles3Gpu, w: s32, h: s32, format: TextureFormat, pixels: *void) -> TextureHandle {
inline if OS != .android { return 0; }
if w <= 0 { return 0; }
if h <= 0 { return 0; }
internal_fmt : s32 = 0;
ext_fmt : u32 = 0;
bpp : u32 = 0;
if format == .rgba8 {
internal_fmt = xx GL_RGBA;
ext_fmt = GL_RGBA;
bpp = 4;
} else {
internal_fmt = xx GL_R8;
ext_fmt = GL_RED;
bpp = 1;
}
t : u32 = 0;
glGenTextures(1, @t);
glBindTexture(GL_TEXTURE_2D, t);
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
glTexImage2D(GL_TEXTURE_2D, 0, internal_fmt, w, h, 0, ext_fmt, GL_UNSIGNED_BYTE, pixels);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, xx GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, xx GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, xx GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, xx GL_CLAMP_TO_EDGE);
slot : Gles3TextureSlot = .{ tex = t, bytes_per_pixel = bpp };
self.textures.append(slot, self.parent_allocator);
xx self.textures.len;
}
update_texture_region :: (self: *Gles3Gpu, handle: TextureHandle, x: s32, y: s32, w: s32, h: s32, pixels: *void) {
inline if OS != .android { return; }
if handle == 0 { return; }
h64 : s64 = xx handle;
if h64 > self.textures.len { return; }
slot := self.textures.items[handle - 1];
if slot.tex == 0 { return; }
if pixels == null { return; }
if w <= 0 { return; }
if h <= 0 { return; }
ext_fmt : u32 = if slot.bytes_per_pixel == 4 then GL_RGBA else GL_RED;
glBindTexture(GL_TEXTURE_2D, slot.tex);
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
glTexSubImage2D(GL_TEXTURE_2D, 0, x, y, w, h, ext_fmt, GL_UNSIGNED_BYTE, pixels);
}
// glDeleteProgram / glDeleteBuffers aren't in opengl.sx's loader yet.
// For now destroy_* is a no-op on Android — resources free on EGL
// context teardown. Worth adding the loader entries when atlas grow
// starts to matter in practice.
destroy_shader :: (self: *Gles3Gpu, sh: ShaderHandle) { }
destroy_buffer :: (self: *Gles3Gpu, buf: BufferHandle) { }
destroy_texture :: (self: *Gles3Gpu, tex: TextureHandle) {
inline if OS != .android { return; }
if tex == 0 { return; }
h64 : s64 = xx tex;
if h64 > self.textures.len { return; }
t := self.textures.items[tex - 1].tex;
if t == 0 { return; }
glDeleteTextures(1, @t);
self.textures.items[tex - 1].tex = 0;
}
// ── Per-draw state ───────────────────────────────────────────────────
set_shader :: (self: *Gles3Gpu, handle: ShaderHandle) {
inline if OS != .android { return; }
if handle == 0 { return; }
h64 : s64 = xx handle;
if h64 > self.shaders.len { return; }
prog := self.shaders.items[handle - 1].program;
if prog == 0 { return; }
glUseProgram(prog);
self.current_shader = handle;
}
set_vertex_buffer :: (self: *Gles3Gpu, handle: BufferHandle) {
inline if OS != .android { return; }
buf := gles3_lookup_buffer(self, handle);
if buf == 0 { return; }
glBindVertexArray(self.vao);
glBindBuffer(GL_ARRAY_BUFFER, buf);
// glVertexAttribPointer captures the currently-bound
// GL_ARRAY_BUFFER into the VAO state. Reconfigure each frame
// so the VAO knows about the live buffer; otherwise drawing
// sources attribs from the zero-buffer (invisible geometry).
glVertexAttribPointer(0, 2, GL_FLOAT, 0, 48, xx 0);
glEnableVertexAttribArray(0);
glVertexAttribPointer(1, 2, GL_FLOAT, 0, 48, xx 8);
glEnableVertexAttribArray(1);
glVertexAttribPointer(2, 4, GL_FLOAT, 0, 48, xx 16);
glEnableVertexAttribArray(2);
glVertexAttribPointer(3, 4, GL_FLOAT, 0, 48, xx 32);
glEnableVertexAttribArray(3);
}
set_texture :: (self: *Gles3Gpu, slot: u32, handle: TextureHandle) {
inline if OS != .android { return; }
if slot != 0 { return; } // renderer only uses unit 0
if handle == 0 { return; }
h64 : s64 = xx handle;
if h64 > self.textures.len { return; }
t := self.textures.items[handle - 1].tex;
if t == 0 { return; }
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, t);
if self.current_shader != 0 {
cs64 : s64 = xx self.current_shader;
if cs64 <= self.shaders.len {
loc := self.shaders.items[self.current_shader - 1].tex_loc;
if loc >= 0 { glUniform1i(loc, 0); }
}
}
}
// For slot 1 + 64 bytes the renderer is uploading the 4x4 projection
// matrix into `uniform mat4 uProj`. Look up via the current shader's
// cached uniform location.
set_vertex_constants :: (self: *Gles3Gpu, slot: u32, data: *void, size_bytes: s64) {
inline if OS != .android { return; }
if slot != 1 { return; }
if size_bytes != 64 { return; }
if self.current_shader == 0 { return; }
cs64 : s64 = xx self.current_shader;
if cs64 > self.shaders.len { return; }
loc := self.shaders.items[self.current_shader - 1].proj_loc;
if loc < 0 { return; }
glUniformMatrix4fv(loc, 1, 0, xx data);
}
set_scissor :: (self: *Gles3Gpu, x: s32, y: s32, w: s32, h: s32) {
inline if OS != .android { return; }
glEnable(GL_SCISSOR_TEST);
glScissor(x, self.pixel_h - (y + h), w, h);
}
disable_scissor :: (self: *Gles3Gpu) {
inline if OS != .android { return; }
glDisable(GL_SCISSOR_TEST);
}
draw_triangles :: (self: *Gles3Gpu, vertex_offset: s32, vertex_count: s32) {
inline if OS != .android { return; }
if vertex_count <= 0 { return; }
glDrawArrays(GL_TRIANGLES, vertex_offset, vertex_count);
}
}
gles3_lookup_buffer :: (self: *Gles3Gpu, handle: u32) -> u32 {
inline if OS != .android { return 0; }
if handle == 0 { return 0; }
h64 : s64 = xx handle;
if h64 > self.buffers.len { return 0; }
self.buffers.items[handle - 1];
}