diff options
| author | Gabriel Schneider <[email protected]> | 2026-07-02 21:56:01 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-01 15:02:07 -0300 |
| commit | 589269c0b3ca74a29f5889fa6e781e59cef7efdb (patch) | |
| tree | ecd2ce3060d06353f3b6302e70abf784295ff40f /ui_sdl.zig | |
| parent | 81992cc92743a98066ee1a9025bc74f91cf0e967 (diff) | |
| download | pardes-589269c0b3ca74a29f5889fa6e781e59cef7efdb.tar.gz pardes-589269c0b3ca74a29f5889fa6e781e59cef7efdb.zip | |
wasm replay ui
Diffstat (limited to 'ui_sdl.zig')
| -rw-r--r-- | ui_sdl.zig | 291 |
1 files changed, 273 insertions, 18 deletions
@@ -24,10 +24,12 @@ const builtin = @import("builtin"); const vaxis = @import("vaxis"); const posix = std.posix; const linux = std.os.linux; +const is_emscripten = builtin.os.tag == .emscripten; pub const c = @cImport({ @cDefine("SDL_DISABLE_OLD_NAMES", "1"); @cInclude("SDL3/SDL.h"); + if (is_emscripten) @cInclude("GLES3/gl3.h"); @cInclude("font.h"); }); @@ -61,8 +63,10 @@ pub fn Loop(comptime EventType: type) type { } const font_ttf = @embedFile("assets/DejaVuSansMono.ttf"); -const vert_spv_bytes = @embedFile("ui.vert.spv"); -const frag_spv_bytes = @embedFile("ui.frag.spv"); +const vert_spv_bytes = if (is_emscripten) "" else @embedFile("ui.vert.spv"); +const frag_spv_bytes = if (is_emscripten) "" else @embedFile("ui.frag.spv"); +const vert_glsl_es = if (is_emscripten) @embedFile("ui.vert.es.glsl") else ""; +const frag_glsl_es = if (is_emscripten) @embedFile("ui.frag.es.glsl") else ""; const log = std.log.scoped(.ui_sdl); @@ -104,6 +108,15 @@ cell_cache_valid: bool = false, last_render_w: u32 = 0, last_render_h: u32 = 0, +// Emscripten/browser path: SDL window + WebGL2 shader renderer. Handles are GL +// GLuint/GLint values, kept as plain C integers so native builds need no GL API. +gl_context: ?c.SDL_GLContext = null, +gl_program: c_uint = 0, +gl_atlas_tex: c_uint = 0, +gl_vbo: c_uint = 0, +gl_u_atlas: c_int = -1, +gl_instances: []CellInstance = &.{}, + // font + cell metrics font: ?*c.UIFont, scale: f32, @@ -189,6 +202,7 @@ fn envFlag(env_map: *std.process.Environ.Map, name: []const u8) bool { } fn nowNs() i128 { + if (is_emscripten) return @intCast(c.SDL_GetTicksNS()); var ts: linux.timespec = undefined; _ = linux.clock_gettime(linux.CLOCK.MONOTONIC, &ts); return @as(i128, ts.sec) * 1_000_000_000 + @as(i128, ts.nsec); @@ -259,7 +273,7 @@ pub fn init( const trace_enabled = envFlag(env_map, "PARDES_SDL_TRACE"); const trace_every = envU32(env_map, "PARDES_SDL_TRACE_EVERY") orelse 1; var trace_fd: c_int = -1; - if (trace_enabled) { + if (!is_emscripten and trace_enabled) { if (env_map.get("PARDES_SDL_TRACE_FILE")) |path| { trace_fd = openTraceFile(path); } else if (capture_dir) |dir| { @@ -278,10 +292,9 @@ pub fn init( const win_w: c_int = 1120; const win_h: c_int = 720; - const win_flags: c.SDL_WindowFlags = if (test_mode) - c.SDL_WINDOW_RESIZABLE - else - c.SDL_WINDOW_HIGH_PIXEL_DENSITY | c.SDL_WINDOW_RESIZABLE; + var win_flags: c.SDL_WindowFlags = c.SDL_WINDOW_RESIZABLE; + if (!test_mode) win_flags |= c.SDL_WINDOW_HIGH_PIXEL_DENSITY; + if (is_emscripten) win_flags |= c.SDL_WINDOW_OPENGL; const sdl_win = c.SDL_CreateWindow( "pardes", win_w, @@ -291,6 +304,9 @@ pub fn init( log.err("SDL_CreateWindow: {s}", .{c.SDL_GetError()}); return error.SdlInit; }; + if (is_emscripten) { + return initGl(io, alloc, sdl_win, test_mode, capture_dir, trace_enabled, trace_fd, trace_every); + } const device = c.SDL_CreateGPUDevice( c.SDL_GPU_SHADERFORMAT_SPIRV, @@ -410,6 +426,98 @@ pub fn init( return self; } +fn initGl( + io: std.Io, + alloc: std.mem.Allocator, + sdl_win: *c.SDL_Window, + test_mode: bool, + capture_dir: ?[]const u8, + trace_enabled: bool, + trace_fd: c_int, + trace_every: u32, +) !SdlBackend { + _ = c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_PROFILE_MASK, c.SDL_GL_CONTEXT_PROFILE_ES); + _ = c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_MAJOR_VERSION, 3); + _ = c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_MINOR_VERSION, 0); + _ = c.SDL_GL_SetAttribute(c.SDL_GL_DOUBLEBUFFER, 1); + const gl_context = c.SDL_GL_CreateContext(sdl_win) orelse { + log.err("SDL_GL_CreateContext: {s}", .{c.SDL_GetError()}); + return error.SdlInit; + }; + if (!c.SDL_GL_MakeCurrent(sdl_win, gl_context)) { + log.err("SDL_GL_MakeCurrent: {s}", .{c.SDL_GetError()}); + return error.SdlInit; + } + _ = c.SDL_GL_SetSwapInterval(1); + + const font = c.ui_font_new(font_ttf.ptr, @intCast(font_ttf.len)) orelse { + log.err("ui_font_new failed", .{}); + return error.FontInit; + }; + const px: f32 = 18.0; + const scale = c.ui_font_scale_for_height(font, px); + var cw: c_int = 10; + var chh: c_int = 20; + var asc: c_int = 16; + c.ui_font_cell_metrics(font, scale, &cw, &chh, &asc); + + const program = try makeGlProgram(); + var atlas_tex: c_uint = 0; + c.glGenTextures(1, &atlas_tex); + c.glBindTexture(c.GL_TEXTURE_2D, atlas_tex); + c.glTexParameteri(c.GL_TEXTURE_2D, c.GL_TEXTURE_MIN_FILTER, c.GL_LINEAR); + c.glTexParameteri(c.GL_TEXTURE_2D, c.GL_TEXTURE_MAG_FILTER, c.GL_LINEAR); + c.glTexParameteri(c.GL_TEXTURE_2D, c.GL_TEXTURE_WRAP_S, c.GL_CLAMP_TO_EDGE); + c.glTexParameteri(c.GL_TEXTURE_2D, c.GL_TEXTURE_WRAP_T, c.GL_CLAMP_TO_EDGE); + c.glPixelStorei(c.GL_UNPACK_ALIGNMENT, 1); + c.glTexImage2D(c.GL_TEXTURE_2D, 0, c.GL_R8, atlas_w, atlas_h, 0, c.GL_RED, c.GL_UNSIGNED_BYTE, null); + + var vbo: c_uint = 0; + c.glGenBuffers(1, &vbo); + + const atlas_stage = try alloc.alloc(u8, atlas_w * atlas_h); + @memset(atlas_stage, 0); + + var self: SdlBackend = .{ + .io = io, + .alloc = alloc, + .sdl_window = sdl_win, + .device = undefined, + .swapchain_format = undefined, + .present_mode = c.SDL_GPU_PRESENTMODE_VSYNC, + .pipeline = undefined, + .atlas_tex = undefined, + .atlas_sampler = undefined, + .atlas_xfer = undefined, + .vbuf = undefined, + .vxfer = undefined, + .gl_context = gl_context, + .gl_program = program, + .gl_atlas_tex = atlas_tex, + .gl_vbo = vbo, + .gl_u_atlas = c.glGetUniformLocation(program, "u_atlas"), + .font = font, + .scale = scale, + .cell_w = @intCast(@max(cw, 1)), + .cell_h = @intCast(@max(chh, 1)), + .ascent = asc, + .screen = .{}, + .atlas_stage = atlas_stage, + .glyphs = std.AutoHashMap(u32, Slot).init(alloc), + .test_mode = test_mode, + .capture_images = false, + .capture_dir = capture_dir, + .trace_enabled = trace_enabled, + .trace_fd = trace_fd, + .trace_every = @max(trace_every, 1), + }; + _ = c.ui_font_raster(font, scale, ' ', atlas_stage.ptr, @intCast(atlas_w), @intCast(self.cell_w), @intCast(self.cell_h), asc); + self.space_slot = .{ .u = 0, .v = 0 }; + self.pen_x = self.cell_w; + self.tracef("sdl.init browser=webgl2 trace_every={d}", .{self.trace_every}); + return self; +} + // Headless debug init for PARDES_TEST_GRID=1: no SDL/GPU/window, just the cell // grid. Input comes from stdin (the pty), output goes to stdout (grid text // snapshots). Cell metrics are dummies: the pty gives us rows/cols directly. @@ -447,6 +555,20 @@ pub fn deinit(self: *SdlBackend, alloc: ?std.mem.Allocator, writer: *std.Io.Writ self.glyphs.deinit(); return; } + if (is_emscripten) { + if (self.gl_vbo != 0) c.glDeleteBuffers(1, &self.gl_vbo); + if (self.gl_atlas_tex != 0) c.glDeleteTextures(1, &self.gl_atlas_tex); + if (self.gl_program != 0) c.glDeleteProgram(self.gl_program); + self.alloc.free(self.gl_instances); + if (self.font) |f| c.ui_font_free(f); + self.screen.deinit(self.alloc); + self.glyphs.deinit(); + if (alloc) |a| a.free(self.atlas_stage); + if (self.gl_context) |ctx| _ = c.SDL_GL_DestroyContext(ctx); + c.SDL_DestroyWindow(self.sdl_window); + c.SDL_Quit(); + return; + } if (self.capture_tex) |t| c.SDL_ReleaseGPUTexture(self.device, t); if (self.capture_xfer) |x| c.SDL_ReleaseGPUTransferBuffer(self.device, x); if (self.trace_fd >= 0 and self.trace_fd != 2) _ = linux.close(self.trace_fd); @@ -476,7 +598,7 @@ pub fn resize(self: *SdlBackend, alloc: std.mem.Allocator, writer: *std.Io.Write self.screen.deinit(alloc); self.screen = try vaxis.Screen.init(alloc, ws); self.screen.width_method = .wcwidth; - if (self.capture_images) { + if (self.capture_images or is_emscripten) { _ = c.SDL_SetWindowSize( self.sdl_window, @intCast(@as(u32, ws.cols) * self.cell_w), @@ -484,6 +606,7 @@ pub fn resize(self: *SdlBackend, alloc: std.mem.Allocator, writer: *std.Io.Write ); _ = c.SDL_SyncWindow(self.sdl_window); } + if (is_emscripten) return; if (!self.grid_test_mode) try self.ensureVbuf(ws.cols * ws.rows); } @@ -501,7 +624,8 @@ pub fn window(self: *SdlBackend) vaxis.Window { pub fn render(self: *SdlBackend, writer: *std.Io.Writer) !void { _ = writer; - if (self.grid_test_mode) return self.dumpGrid(); + if (!is_emscripten and self.grid_test_mode) return self.dumpGrid(); + if (is_emscripten) return self.renderGl(); const trace_this = self.trace_enabled and (self.trace_frame % self.trace_every == 0); const t0 = nowNs(); @@ -665,6 +789,69 @@ pub fn render(self: *SdlBackend, writer: *std.Io.Writer) !void { self.trace_frame += 1; } +fn renderGl(self: *SdlBackend) !void { + var pw: c_int = 0; + var ph: c_int = 0; + _ = c.SDL_GetWindowSizeInPixels(self.sdl_window, &pw, &ph); + const sw: u32 = @max(1, @as(u32, @intCast(pw))); + const sh: u32 = @max(1, @as(u32, @intCast(ph))); + c.glViewport(0, 0, @intCast(sw), @intCast(sh)); + c.glClearColor( + @as(f32, @floatFromInt(self.bg_default[0])) / 255.0, + @as(f32, @floatFromInt(self.bg_default[1])) / 255.0, + @as(f32, @floatFromInt(self.bg_default[2])) / 255.0, + 1.0, + ); + c.glClear(c.GL_COLOR_BUFFER_BIT); + + const cols = self.screen.width; + const rows = self.screen.height; + const cells: u32 = @as(u32, cols) * @as(u32, rows); + if (cells == 0) { + _ = c.SDL_GL_SwapWindow(self.sdl_window); + return; + } + + try self.ensureGlInstances(cells); + const cw_f: f32 = @floatFromInt(self.cell_w); + const ch_f: f32 = @floatFromInt(self.cell_h); + const win_w: f32 = @floatFromInt(sw); + const win_h: f32 = @floatFromInt(sh); + var r: u16 = 0; + while (r < rows) : (r += 1) { + var col: u16 = 0; + while (col < cols) : (col += 1) { + const cell_index: u32 = @as(u32, r) * @as(u32, cols) + @as(u32, col); + self.emitInstance(self.gl_instances.ptr, cell_index, col, r, cw_f, ch_f, win_w, win_h, self.cellKey(self.screen.buf[cell_index])); + } + } + if (self.atlas_dirty) self.uploadAtlasGl(); + + c.glUseProgram(self.gl_program); + c.glActiveTexture(c.GL_TEXTURE0); + c.glBindTexture(c.GL_TEXTURE_2D, self.gl_atlas_tex); + c.glUniform1i(self.gl_u_atlas, 0); + + c.glBindBuffer(c.GL_ARRAY_BUFFER, self.gl_vbo); + c.glBufferData(c.GL_ARRAY_BUFFER, @intCast(cells * @sizeOf(CellInstance)), self.gl_instances.ptr, c.GL_STREAM_DRAW); + const stride: c_int = @intCast(@sizeOf(CellInstance)); + c.glEnableVertexAttribArray(0); + c.glVertexAttribPointer(0, 4, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(@offsetOf(CellInstance, "x0"))); + c.glVertexAttribDivisor(0, 1); + c.glEnableVertexAttribArray(1); + c.glVertexAttribPointer(1, 4, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(@offsetOf(CellInstance, "u0"))); + c.glVertexAttribDivisor(1, 1); + c.glEnableVertexAttribArray(2); + c.glVertexAttribPointer(2, 3, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(@offsetOf(CellInstance, "fr"))); + c.glVertexAttribDivisor(2, 1); + c.glEnableVertexAttribArray(3); + c.glVertexAttribPointer(3, 3, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(@offsetOf(CellInstance, "br"))); + c.glVertexAttribDivisor(3, 1); + + c.glDrawArraysInstanced(c.GL_TRIANGLES, 0, 6, @intCast(cells)); + _ = c.SDL_GL_SwapWindow(self.sdl_window); +} + // PARDES_TEST_GRID debug mode: dump the retained cell grid as text to stdout // (fd 1). One frame per render, delimited by ---FRAME---/---ENDFRAME--- markers. fn dumpGrid(self: *SdlBackend) !void { @@ -876,6 +1063,7 @@ fn writeAllFdIgnore(fd: c_int, bytes: []const u8) void { } fn tracef(self: *SdlBackend, comptime fmt: []const u8, args: anytype) void { + if (is_emscripten) return; if (!self.trace_enabled or self.trace_fd < 0) return; var buf: [1024]u8 = undefined; const line = std.fmt.bufPrint(&buf, fmt ++ "\n", args) catch return; @@ -1000,6 +1188,12 @@ fn ensureVbuf(self: *SdlBackend, cells: u32) !void { self.vbuf_cells = cells; } +fn ensureGlInstances(self: *SdlBackend, cells: u32) !void { + if (self.gl_instances.len >= cells) return; + self.alloc.free(self.gl_instances); + self.gl_instances = try self.alloc.alloc(CellInstance, cells); +} + fn resolveColor(self: *const SdlBackend, col: vaxis.Color, is_bg: bool) [3]u8 { return switch (col) { .default => if (is_bg) self.bg_default else self.fg_default, @@ -1201,6 +1395,13 @@ fn uploadAtlas(self: *SdlBackend, cmd: *c.SDL_GPUCommandBuffer) void { self.atlas_dirty = false; } +fn uploadAtlasGl(self: *SdlBackend) void { + c.glBindTexture(c.GL_TEXTURE_2D, self.gl_atlas_tex); + c.glPixelStorei(c.GL_UNPACK_ALIGNMENT, 1); + c.glTexSubImage2D(c.GL_TEXTURE_2D, 0, 0, 0, atlas_w, atlas_h, c.GL_RED, c.GL_UNSIGNED_BYTE, self.atlas_stage.ptr); + self.atlas_dirty = false; +} + fn makePipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFormat) !*c.SDL_GPUGraphicsPipeline { const vs = try makeShader(device, vert_spv_bytes, c.SDL_GPU_SHADERSTAGE_VERTEX, 0); defer c.SDL_ReleaseGPUShader(device, vs); @@ -1257,6 +1458,44 @@ fn makeShader( return c.SDL_CreateGPUShader(device, &info) orelse error.GpuCreate; } +fn makeGlProgram() !c_uint { + const vs = try makeGlShader(c.GL_VERTEX_SHADER, vert_glsl_es); + defer c.glDeleteShader(vs); + const fs = try makeGlShader(c.GL_FRAGMENT_SHADER, frag_glsl_es); + defer c.glDeleteShader(fs); + + const program = c.glCreateProgram(); + if (program == 0) return error.GpuCreate; + c.glAttachShader(program, vs); + c.glAttachShader(program, fs); + c.glLinkProgram(program); + var ok: c_int = 0; + c.glGetProgramiv(program, c.GL_LINK_STATUS, &ok); + if (ok == 0) { + log.err("WebGL program link failed", .{}); + c.glDeleteProgram(program); + return error.GpuCreate; + } + return program; +} + +fn makeGlShader(kind: c_uint, source: []const u8) !c_uint { + const shader = c.glCreateShader(kind); + if (shader == 0) return error.GpuCreate; + var src_ptr = [_][*c]const u8{@ptrCast(source.ptr)}; + var src_len = [_]c_int{@intCast(source.len)}; + c.glShaderSource(shader, 1, &src_ptr, &src_len); + c.glCompileShader(shader); + var ok: c_int = 0; + c.glGetShaderiv(shader, c.GL_COMPILE_STATUS, &ok); + if (ok == 0) { + log.err("WebGL shader compile failed", .{}); + c.glDeleteShader(shader); + return error.GpuCreate; + } + return shader; +} + // ===================================================================== // Tty stub — the app threads a `*std.Io.Writer` through every backend call; SDL // needs none of it, so we back it with /dev/null. The pointer just has to stay @@ -1264,17 +1503,26 @@ fn makeShader( // ===================================================================== const SdlTty = struct { - file: std.Io.File, - tty_writer: std.Io.File.Writer, + const File = if (is_emscripten) void else std.Io.File; + const Writer = if (is_emscripten) std.Io.Writer.Discarding else std.Io.File.Writer; + + file: File, + tty_writer: Writer, pub fn init(io: std.Io, buffer: []u8) !SdlTty { + if (is_emscripten) return .{ + .file = {}, + .tty_writer = .init(buffer), + }; const f = try std.Io.Dir.openFileAbsolute(io, "/dev/null", .{ .mode = .read_write }); return .{ .file = f, .tty_writer = f.writerStreaming(io, buffer) }; } pub fn deinit(self: SdlTty, io: std.Io) void { + if (is_emscripten) return; self.file.close(io); } pub fn writer(self: *SdlTty) *std.Io.Writer { + if (is_emscripten) return &self.tty_writer.writer; return &self.tty_writer.interface; } }; @@ -1288,6 +1536,7 @@ const SdlTty = struct { fn SdlLoop(comptime EventType: type) type { return struct { const Self = @This(); + const Reader = if (is_emscripten) void else ?std.Io.Future(void); io: std.Io, vx: *SdlBackend, @@ -1298,7 +1547,7 @@ fn SdlLoop(comptime EventType: type) type { last_cols: u16 = 0, last_rows: u16 = 0, // test-mode stdin reader task + quit flag - reader: ?std.Io.Future(void) = null, + reader: Reader = if (is_emscripten) {} else null, should_quit: bool = false, pub fn init(io: std.Io, tty: *SdlTty, vx: *SdlBackend) Self { @@ -1307,7 +1556,7 @@ fn SdlLoop(comptime EventType: type) type { } pub fn start(self: *Self) !void { - if (self.vx.test_mode) { + if (!is_emscripten and self.vx.test_mode) { setStdinRaw() catch {}; // e2e: spawn a stdin reader that parses terminal escape sequences // (vaxis.Parser) into Key/Mouse/Winsize events and posts them. @@ -1320,7 +1569,7 @@ fn SdlLoop(comptime EventType: type) type { self.postWinsize(); } pub fn stop(self: *Self) void { - if (self.vx.test_mode) { + if (!is_emscripten and self.vx.test_mode) { self.should_quit = true; if (self.reader) |*r| r.await(self.io); self.reader = null; @@ -1328,7 +1577,7 @@ fn SdlLoop(comptime EventType: type) type { } } pub fn installResizeHandler(self: *Self) !void { - if (self.vx.test_mode) { + if (!is_emscripten and self.vx.test_mode) { var act = posix.Sigaction{ .handler = .{ .handler = winchHandler }, .mask = switch (builtin.os.tag) { @@ -1355,7 +1604,7 @@ fn SdlLoop(comptime EventType: type) type { } pub fn nextEvent(self: *Self) !EventType { - if (self.vx.test_mode) return try self.queue.pop(); + if (!is_emscripten and self.vx.test_mode) return try self.queue.pop(); while (true) { // pty/queued events first (they're already decoded) if (try self.queue.tryPop()) |ev| { @@ -1379,11 +1628,17 @@ fn SdlLoop(comptime EventType: type) type { } pub fn tryEvent(self: *Self) !?EventType { - return try self.queue.tryPop(); + if (try self.queue.tryPop()) |ev| return ev; + if (!is_emscripten and self.vx.test_mode) return null; + var sev = std.mem.zeroes(c.SDL_Event); + while (c.SDL_PollEvent(&sev)) { + if (try self.dispatch(&sev)) |ev| return ev; + } + return null; } fn postWinsize(self: *Self) void { - if (self.vx.test_mode) { + if (!is_emscripten and self.vx.test_mode) { // read winsize from stdin (fd 0 = the pty slave) var ws: posix.winsize = std.mem.zeroes(posix.winsize); const iorc = posix.system.ioctl(0, posix.T.IOCGWINSZ, @intFromPtr(&ws)); |
