diff --git a/Cargo.lock b/Cargo.lock index 0ea1625..cb1eecc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -642,7 +642,6 @@ dependencies = [ "haku", "log", "paste", - "tiny-skia", ] [[package]] @@ -886,12 +885,6 @@ version = "0.2.155" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" -[[package]] -name = "libm" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8355be11b20d696c8f18f6cc018c4e372165b1fa8126cef092399c9951984ffa" - [[package]] name = "libsqlite3-sys" version = "0.30.1" @@ -1652,7 +1645,6 @@ checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" dependencies = [ "arrayref", "bytemuck", - "libm", "strict-num", ] diff --git a/Cargo.toml b/Cargo.toml index 5fb2332..8e04f74 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,6 @@ haku.path = "crates/haku" haku2.path = "crates/haku2" log = "0.4.22" rkgk-image-ops.path = "crates/rkgk-image-ops" -tiny-skia = { version = "0.11.4", default-features = false } [profile.dev.package.rkgk-image-ops] opt-level = 3 diff --git a/Justfile b/Justfile index 97006bb..61ac93e 100644 --- a/Justfile +++ b/Justfile @@ -4,7 +4,7 @@ wasm_profile := "wasm-" + profile log := "" serve: wasm - RKGK_PORT={{port}} RKGK_WASM_PATH=target/wasm32-unknown-unknown/{{wasm_profile}} RUST_LOG={{log}} cargo run -p rkgk --profile {{profile}} + RKGK_PORT={{port}} RKGK_WASM_PATH=target/wasm32-unknown-unknown/{{wasm_profile}} RUST_LOG={{log}} RUST_BACKTRACE=1 cargo run -p rkgk --profile {{profile}} wasm: cargo build -p haku-wasm --target wasm32-unknown-unknown --profile {{wasm_profile}} diff --git a/crates/haku-wasm/Cargo.toml b/crates/haku-wasm/Cargo.toml index cb4c19c..9b87472 100644 --- a/crates/haku-wasm/Cargo.toml +++ b/crates/haku-wasm/Cargo.toml @@ -11,7 +11,6 @@ arrayvec = { version = "0.7.4", default-features = false } dlmalloc = { version = "0.2.6", features = ["global"] } haku.workspace = true log.workspace = true -tiny-skia = { workspace = true, features = ["no-std-float"] } paste = "1.0.15" [features] diff --git a/crates/haku-wasm/src/lib.rs b/crates/haku-wasm/src/lib.rs index 5030c58..c882252 100644 --- a/crates/haku-wasm/src/lib.rs +++ b/crates/haku-wasm/src/lib.rs @@ -16,10 +16,6 @@ use haku::{ token::Lexis, }; use log::{debug, info}; -use tiny_skia::{ - BlendMode, Color, FillRule, LineCap, Paint, PathBuilder, Pixmap, PremultipliedColorU8, Rect, - Shader, Stroke, Transform, -}; pub mod logging; #[cfg(not(feature = "std"))] @@ -201,147 +197,6 @@ extern "C" fn haku_status_string(code: StatusCode) -> *const i8 { .as_ptr() } -struct PixmapCanvas { - pixmap: Pixmap, - pb: PathBuilder, - transform: Transform, -} - -#[unsafe(no_mangle)] -extern "C" fn haku_pixmap_new(width: u32, height: u32) -> *mut PixmapCanvas { - let ptr = Box::leak(Box::new(PixmapCanvas { - pixmap: Pixmap::new(width, height).expect("invalid pixmap size"), - pb: PathBuilder::new(), - transform: Transform::identity(), - })) as *mut _; - debug!("created pixmap with size {width}x{height}: {ptr:?}"); - ptr -} - -#[unsafe(no_mangle)] -unsafe extern "C" fn haku_pixmap_destroy(c: *mut PixmapCanvas) { - debug!("destroying pixmap: {c:?}"); - drop(Box::from_raw(c)) -} - -#[unsafe(no_mangle)] -unsafe extern "C" fn haku_pixmap_data(c: *mut PixmapCanvas) -> *mut u8 { - let c = &mut *c; - c.pixmap.pixels_mut().as_mut_ptr() as *mut u8 -} - -#[unsafe(no_mangle)] -unsafe extern "C" fn haku_pixmap_clear(c: *mut PixmapCanvas) { - let c = &mut *c; - c.pixmap - .pixels_mut() - .fill(PremultipliedColorU8::TRANSPARENT); -} - -#[unsafe(no_mangle)] -unsafe extern "C" fn haku_pixmap_set_translation(c: *mut PixmapCanvas, x: f32, y: f32) { - let c = &mut *c; - c.transform = Transform::from_translate(x, y); -} - -#[unsafe(no_mangle)] -unsafe extern "C" fn haku_pixmap_begin(c: *mut PixmapCanvas) -> bool { - let c = &mut *c; - c.pb.clear(); - true -} - -#[unsafe(no_mangle)] -unsafe extern "C" fn haku_pixmap_line( - c: *mut PixmapCanvas, - x1: f32, - y1: f32, - x2: f32, - y2: f32, -) -> bool { - let c = &mut *c; - c.pb.move_to(x1, y1); - c.pb.line_to(x2, y2); - true -} - -#[unsafe(no_mangle)] -unsafe extern "C" fn haku_pixmap_rectangle( - c: *mut PixmapCanvas, - x: f32, - y: f32, - width: f32, - height: f32, -) -> bool { - let c = &mut *c; - if let Some(rect) = Rect::from_xywh(x, y, width, height) { - c.pb.push_rect(rect); - } - true -} - -#[unsafe(no_mangle)] -unsafe extern "C" fn haku_pixmap_circle(c: *mut PixmapCanvas, x: f32, y: f32, r: f32) -> bool { - let c = &mut *c; - c.pb.push_circle(x, y, r); - true -} - -fn default_paint() -> Paint<'static> { - Paint { - shader: Shader::SolidColor(Color::BLACK), - blend_mode: BlendMode::SourceOver, - anti_alias: false, - force_hq_pipeline: false, - } -} - -#[unsafe(no_mangle)] -unsafe extern "C" fn haku_pixmap_fill(c: *mut PixmapCanvas, r: u8, g: u8, b: u8, a: u8) -> bool { - let c = &mut *c; - let pb = mem::take(&mut c.pb); - if let Some(path) = pb.finish() { - let paint = Paint { - shader: Shader::SolidColor(Color::from_rgba8(r, g, b, a)), - ..default_paint() - }; - c.pixmap - .fill_path(&path, &paint, FillRule::EvenOdd, c.transform, None); - } - true -} - -#[unsafe(no_mangle)] -unsafe extern "C" fn haku_pixmap_stroke( - c: *mut PixmapCanvas, - r: u8, - g: u8, - b: u8, - a: u8, - thickness: f32, -) -> bool { - let c = &mut *c; - let pb = mem::take(&mut c.pb); - if let Some(path) = pb.finish() { - let paint = Paint { - shader: Shader::SolidColor(Color::from_rgba8(r, g, b, a)), - ..default_paint() - }; - c.pixmap.stroke_path( - &path, - &paint, - &Stroke { - width: thickness, - line_cap: LineCap::Round, - ..Default::default() - }, - c.transform, - None, - ); - } - true -} - #[unsafe(no_mangle)] unsafe extern "C" fn haku_compile_brush( instance: *mut Instance, diff --git a/crates/haku/src/system.rs b/crates/haku/src/system.rs index d75a450..aef6348 100644 --- a/crates/haku/src/system.rs +++ b/crates/haku/src/system.rs @@ -77,13 +77,7 @@ pub fn resolve(arity: SystemFnArity, name: &str) -> Option { (Nary, "reduce") => 0x95, (Nary, "flatten") => 0x96, - (Nary, "toShape") => 0xc0, - (Nary, "line") => 0xc1, - (Nary, "rect") => 0xc2, - (Nary, "circle") => 0xc3, - (Nary, "stroke") => 0xe0, - (Nary, "fill") => 0xe1, (Nary, "withDotter") => 0xf0, diff --git a/crates/haku2/src/canvas.zig b/crates/haku2/src/canvas.zig index cfd32cb..42d4ec8 100644 --- a/crates/haku2/src/canvas.zig +++ b/crates/haku2/src/canvas.zig @@ -8,41 +8,21 @@ pub const Canvas = opaque { if (!status) return error.Draw; } - pub fn begin(c: *Canvas) !void { - try wrap(__haku2_canvas_begin(c)); - } - - pub fn line(c: *Canvas, start: value.Vec2, end: value.Vec2) !void { - const x1, const y1 = start; - const x2, const y2 = end; - try wrap(__haku2_canvas_line(c, x1, y1, x2, y2)); - } - - pub fn rect(c: *Canvas, top_left: value.Vec2, size: value.Vec2) !void { - const x, const y = top_left; - const width, const height = size; - try wrap(__haku2_canvas_rectangle(c, x, y, width, height)); - } - - pub fn circle(c: *Canvas, center: value.Vec2, r: f32) !void { - const x, const y = center; - try wrap(__haku2_canvas_circle(c, x, y, r)); - } - - pub fn fill(c: *Canvas, color: value.Rgba8) !void { + pub fn stroke(c: *Canvas, color: value.Rgba8, thickness: f32, from: value.Vec2, to: value.Vec2) !void { const r, const g, const b, const a = color; - try wrap(__haku2_canvas_fill(c, r, g, b, a)); - } - - pub fn stroke(c: *Canvas, color: value.Rgba8, thickness: f32) !void { - const r, const g, const b, const a = color; - try wrap(__haku2_canvas_stroke(c, r, g, b, a, thickness)); + try wrap(__haku2_canvas_stroke(c, r, g, b, a, thickness, from[0], from[1], to[0], to[1])); } }; -extern fn __haku2_canvas_begin(c: *Canvas) bool; -extern fn __haku2_canvas_line(c: *Canvas, x1: f32, y1: f32, x2: f32, y2: f32) bool; -extern fn __haku2_canvas_rectangle(c: *Canvas, x: f32, y: f32, width: f32, height: f32) bool; -extern fn __haku2_canvas_circle(c: *Canvas, x: f32, y: f32, r: f32) bool; -extern fn __haku2_canvas_fill(c: *Canvas, r: u8, g: u8, b: u8, a: u8) bool; -extern fn __haku2_canvas_stroke(c: *Canvas, r: u8, g: u8, b: u8, a: u8, thickness: f32) bool; +extern fn __haku2_canvas_stroke( + c: *Canvas, + r: u8, + g: u8, + b: u8, + a: u8, + thickness: f32, + from_x: f32, + from_y: f32, + to_x: f32, + to_y: f32, +) bool; diff --git a/crates/haku2/src/render.zig b/crates/haku2/src/render.zig index 08e2de4..d9087f5 100644 --- a/crates/haku2/src/render.zig +++ b/crates/haku2/src/render.zig @@ -24,18 +24,13 @@ fn renderRec(vm: *Vm, canvas: *Canvas, val: Value, depth: usize, max_depth: usiz switch (val.ref.*) { .scribble => { - try canvas.begin(); - - switch (val.ref.scribble.shape) { - .point => |point| try canvas.line(point, point), - .line => |line| try canvas.line(line.start, line.end), - .rect => |rect| try canvas.rect(rect.top_left, rect.size), - .circle => |circle| try canvas.circle(circle.center, circle.radius), - } - - switch (val.ref.scribble.action) { - .stroke => |stroke| try canvas.stroke(value.rgbaTo8(stroke.color), stroke.thickness), - .fill => |fill| try canvas.fill(value.rgbaTo8(fill.color)), + switch (val.ref.scribble) { + .stroke => |stroke| try canvas.stroke( + value.rgbaTo8(stroke.color), + stroke.thickness, + value.vec2From4(stroke.from), + value.vec2From4(stroke.to), + ), } }, @@ -46,10 +41,6 @@ fn renderRec(vm: *Vm, canvas: *Canvas, val: Value, depth: usize, max_depth: usiz } }, - .shape => { - return vm.throw("the brush returned a bare shape, which cannot be drawn. try wrapping your shape in a fill or a stroke: (fill #000 )", .{}); - }, - else => return notAScribble(vm, val), } } diff --git a/crates/haku2/src/system.zig b/crates/haku2/src/system.zig index e165031..e5edbc5 100644 --- a/crates/haku2/src/system.zig +++ b/crates/haku2/src/system.zig @@ -118,14 +118,6 @@ fn fromArgument(cx: Context, comptime T: type, i: usize) Vm.Error!T { if (val != .ref or val.ref.* != .list) return typeError(cx.vm, val, i, "list"); return val.ref.list; }, - value.Shape => { - const val = cx.args[i]; - if (toShape(val)) |shape| { - return shape; - } else { - return typeError(cx.vm, val, i, "shape"); - } - }, *const value.Closure => { const val = cx.args[i]; if (val != .ref or val.ref.* != .closure) return typeError(cx.vm, val, i, "function"); @@ -282,12 +274,7 @@ pub const fns = makeFnTable(&[_]SparseFn{ .{ 0x94, erase("filter", filter) }, .{ 0x95, erase("reduce", reduce) }, .{ 0x96, erase("flatten", flatten) }, - .{ 0xc0, erase("toShape", valueToShape) }, - .{ 0xc1, erase("line", line) }, - .{ 0xc2, erase("rect", rect) }, - .{ 0xc3, erase("circle", circle) }, .{ 0xe0, erase("stroke", stroke) }, - .{ 0xe1, erase("fill", fill) }, .{ 0xf0, erase("withDotter", withDotter) }, }); @@ -738,51 +725,14 @@ fn flatten(list: value.List, cx: Context) Vm.Error!value.Ref { return .{ .list = flattened_list }; } -fn toShape(val: value.Value) ?value.Shape { - return switch (val) { - .nil, .false, .true, .tag, .number, .rgba => null, - .vec4 => |v| .{ .point = value.vec2From4(v) }, - .ref => |r| if (r.* == .shape) r.shape else null, - }; -} - -/// `toShape` -fn valueToShape(val: value.Value) ?value.Ref { - if (toShape(val)) |shape| { - return .{ .shape = shape }; - } else { - return null; - } -} - -fn line(start: Vec4, end: Vec4) value.Ref { - return .{ .shape = .{ .line = .{ - .start = value.vec2From4(start.value), - .end = value.vec2From4(end.value), - } } }; -} - -fn rect(top_left: Vec4, size: Vec4) value.Ref { - return .{ .shape = .{ .rect = .{ - .top_left = value.vec2From4(top_left.value), - .size = value.vec2From4(size.value), - } } }; -} - -fn circle(center: Vec4, radius: f32) value.Ref { - return .{ .shape = .{ .circle = .{ - .center = value.vec2From4(center.value), - .radius = radius, - } } }; -} - -fn stroke(thickness: f32, color: Rgba, shape: value.Shape) value.Ref { +fn stroke(thickness: f32, color: Rgba, from: Vec4, to: Vec4) value.Ref { return .{ .scribble = .{ - .shape = shape, - .action = .{ .stroke = .{ + .stroke = .{ .thickness = thickness, .color = color.value, - } }, + .from = from.value, + .to = to.value, + }, } }; } diff --git a/crates/haku2/src/value.zig b/crates/haku2/src/value.zig index 3269a60..fdb8c47 100644 --- a/crates/haku2/src/value.zig +++ b/crates/haku2/src/value.zig @@ -62,7 +62,6 @@ pub const Value = union(enum) { .ref => |r| switch (r.*) { .closure => "function", .list => "list", - .shape => "shape", .scribble => "scribble", .reticle => "reticle", }, @@ -86,7 +85,7 @@ pub const Value = union(enum) { } try writer.writeAll("]"); }, - inline .shape, .scribble, .reticle => |x| try writer.print("{}", .{x}), + inline .scribble, .reticle => |x| try writer.print("{}", .{x}), }, } } @@ -122,7 +121,6 @@ pub fn rgbaTo8(rgba: Rgba) Rgba8 { pub const Ref = union(enum) { closure: Closure, list: List, - shape: Shape, scribble: Scribble, reticle: Reticle, }; @@ -161,44 +159,14 @@ pub const Closure = struct { pub const List = []Value; -pub const Shape = union(enum) { - point: Vec2, - line: Line, - rect: Rect, - circle: Circle, +pub const Scribble = union(enum) { + stroke: Stroke, - pub const Line = struct { - start: Vec2, - end: Vec2, - }; - - pub const Rect = struct { - top_left: Vec2, - size: Vec2, - }; - - pub const Circle = struct { - center: Vec2, - radius: f32, - }; -}; - -pub const Scribble = struct { - shape: Shape, - action: Action, - - pub const Action = union(enum) { - stroke: Stroke, - fill: Fill, - - pub const Stroke = struct { - thickness: f32, - color: Rgba, - }; - - pub const Fill = struct { - color: Rgba, - }; + pub const Stroke = struct { + thickness: f32, + color: Rgba, + from: Vec4, + to: Vec4, }; }; diff --git a/crates/rkgk/src/api/wall.rs b/crates/rkgk/src/api/wall.rs index 4091426..1f38d1a 100644 --- a/crates/rkgk/src/api/wall.rs +++ b/crates/rkgk/src/api/wall.rs @@ -94,7 +94,8 @@ async fn fallible_websocket(api: Arc, ws: &mut WebSocket) -> eyre::Result<( ws.send(to_message(&Version { version })).await?; - let login_request: LoginRequest = from_message(&recv_expect(ws).await?)?; + let login_request: LoginRequest = + from_message(&recv_expect(ws).await?).context("LoginRequest")?; let user_id = login_request.user; let secret = base64::engine::general_purpose::URL_SAFE .decode(&login_request.secret) @@ -284,7 +285,8 @@ impl SessionLoop { loop { select! { Some(message) = ws.recv() => { - let request = from_message(&message?)?; + let message = message?; + let request = from_message(&message).context("Request")?; self.process_request(ws, request).await?; } diff --git a/crates/rkgk/src/main.rs b/crates/rkgk/src/main.rs index bd87b52..7189efe 100644 --- a/crates/rkgk/src/main.rs +++ b/crates/rkgk/src/main.rs @@ -93,7 +93,10 @@ async fn main() { #[cfg(feature = "memory-profiling")] let _client = tracy_client::Client::start(); - color_eyre::install().unwrap(); + color_eyre::config::HookBuilder::new() + .theme(color_eyre::config::Theme::new()) + .install() + .unwrap(); tracing_subscriber::registry() .with(tracing_subscriber::fmt::layer()) .with( diff --git a/static/brush-box.js b/static/brush-box.js index bee6be6..78baefb 100644 --- a/static/brush-box.js +++ b/static/brush-box.js @@ -3,6 +3,7 @@ import { Haku } from "rkgk/haku.js"; import { randomId } from "rkgk/random.js"; import { SaveData } from "rkgk/framework.js"; import { ContextMenu, globalContextMenuSpace } from "rkgk/context-menu.js"; +import { BrushRenderer } from "rkgk/brush-renderer.js"; export const builtInPresets = [ { @@ -15,7 +16,7 @@ color: #000 thickness: 8 withDotter \\d -> - stroke thickness color (line d.From d.To) + stroke thickness color d.From d.To `.trim(), }, @@ -27,7 +28,7 @@ color: #000 thickness: 48 withDotter \\d -> - stroke thickness color (line d.From d.To) + stroke thickness color d.From d.To `.trim(), }, @@ -43,7 +44,7 @@ duty: 0.5 withDotter \\d -> visible? = d.Num |mod length < length * duty if (visible?) - stroke thickness color (line d.From d.To) + stroke thickness color d.From d.To else () `.trim(), @@ -57,7 +58,7 @@ color: #0003 thickness: 6 withDotter \\d -> - stroke thickness color (line d.From d.To) + stroke thickness color d.From d.To `.trim(), }, @@ -75,7 +76,7 @@ withDotter \\d -> a = sin (d.Num * wavelength / pi) + 1 / 2 range = maxThickness - minThickness thickness = a * range + minThickness - stroke thickness color (line d.From d.To) + stroke thickness color d.From d.To `.trim(), }, @@ -104,7 +105,7 @@ withDotter \\d -> clockwise = norm (perpClockwise d.To-d.From) * vec a a from = d.From + clockwise to = d.To + clockwise - stroke thickness color (line from to) + stroke thickness color from to `.trim(), }, @@ -125,7 +126,7 @@ withDotter \\d -> g = colorCurve (d.Num * l + pi/3) b = colorCurve (d.Num * l + 2*pi/3) color = rgba r g b 1 - stroke thickness color (line d.From d.To) + stroke thickness color d.From d.To `.trim(), }, @@ -134,7 +135,8 @@ withDotter \\d -> function presetButton(info) { let button = document.createElement("button"); - let preview = button.appendChild(new BrushPreview(56, 56)); + let preview = button.appendChild(document.createElement("div")); + preview.classList.add("preview"); let label = button.appendChild(document.createElement("p")); label.classList.add("label"); label.innerText = info.name; @@ -172,6 +174,14 @@ export class BrushBox extends HTMLElement { initialize(wallLimits) { this.haku = new Haku(wallLimits); + + this.brushesCanvas = this.appendChild(document.createElement("canvas")); + this.gl = this.brushesCanvas.getContext("webgl2"); + let canvasResizeObserver = new ResizeObserver(() => this.renderBrushes()); + canvasResizeObserver.observe(this); + + this.brushRenderer = new BrushRenderer(this.gl, this.canvasSource()); + this.renderBrushes(); } @@ -290,10 +300,61 @@ export class BrushBox extends HTMLElement { if (this.currentPresetId != null) this.setCurrentBrush(this.currentPresetId); } + canvasSource() { + let brushBox = this; + return { + useCanvas(gl, i) { + let brush = brushBox.brushes[i]; + + let canvasRect = brushBox.brushesCanvas.getBoundingClientRect(); + let previewRect = brush.presetButton.preview.getBoundingClientRect(); + let viewport = { + x: previewRect.x - canvasRect.x, + y: canvasRect.bottom - previewRect.bottom, + width: previewRect.width, + height: previewRect.height, + }; + + gl.enable(gl.SCISSOR_TEST); + gl.viewport(viewport.x, viewport.y, viewport.width, viewport.height); + gl.scissor(viewport.x, viewport.y, viewport.width, viewport.height); + + return viewport; + }, + + resetCanvas(gl) {}, + }; + } + async renderBrushes() { - for (let brush of this.brushes) { + let gl = this.gl; + + this.brushesCanvas.width = this.brushesCanvas.clientWidth; + this.brushesCanvas.height = this.brushesContainer.scrollHeight; + + gl.clearColor(0, 0, 0, 0); + gl.clear(gl.COLOR_BUFFER_BIT); + + for (let i = 0; i < this.brushes.length; ++i) { + let brush = this.brushes[i]; + let previewRect = brush.presetButton.preview.getBoundingClientRect(); + this.haku.setBrush(brush.preset.code); - await brush.presetButton.preview.renderBrush(this.haku); + await this.haku.evalBrush({ + runDotter: async () => { + return { + fromX: previewRect.width / 2, + fromY: previewRect.height / 2, + toX: previewRect.width / 2, + toY: previewRect.height / 2, + num: 0, + }; + }, + + runScribble: async (renderToCanvas) => { + renderToCanvas(this.brushRenderer, i, 0, 0); + }, + }); } } diff --git a/static/brush-preview.js b/static/brush-preview.js index 97a2015..cca3150 100644 --- a/static/brush-preview.js +++ b/static/brush-preview.js @@ -1,5 +1,3 @@ -import { Pixmap } from "rkgk/haku.js"; - export class BrushPreview extends HTMLElement { constructor(width, height) { super(); @@ -9,53 +7,11 @@ export class BrushPreview extends HTMLElement { } connectedCallback() { - this.canvas = this.appendChild(document.createElement("canvas")); - this.ctx = this.canvas.getContext("2d"); - - this.#resizeCanvas(); - if (this.width == null || this.height == null) { - new ResizeObserver(() => this.#resizeCanvas()).observe(this); - } - } - - #resizeCanvas() { - this.canvas.width = this.width ?? this.clientWidth; - this.canvas.height = this.height ?? this.clientHeight; - - // This can happen if the element's `display: none`. - if (this.canvas.width == 0 || this.canvas.height == 0) return; - - if (this.pixmap != null) { - this.pixmap.destroy(); - } - this.pixmap = new Pixmap(this.canvas.width, this.canvas.height); - - this.dispatchEvent(new Event(".pixmapLost")); + // TODO } async #renderBrushInner(haku) { - this.pixmap.clear(); - let evalResult = await haku.evalBrush({ - runDotter: async () => { - return { - fromX: this.canvas.width / 2, - fromY: this.canvas.height / 2, - toX: this.canvas.width / 2, - toY: this.canvas.height / 2, - num: 0, - }; - }, - - runScribble: async (renderToPixmap) => { - return renderToPixmap(this.pixmap, 0, 0); - }, - }); - if (evalResult.status != "ok") { - return { status: "error", phase: "eval", result: evalResult }; - } - - this.ctx.putImageData(this.pixmap.getImageData(), 0, 0); - + // TODO return { status: "ok" }; } diff --git a/static/brush-renderer.js b/static/brush-renderer.js new file mode 100644 index 0000000..462012e --- /dev/null +++ b/static/brush-renderer.js @@ -0,0 +1,212 @@ +import { compileProgram, orthographicProjection } from "rkgk/webgl.js"; + +const linesVertexShader = `#version 300 es + precision highp float; + + uniform mat4 u_projection; + uniform vec2 u_translation; + + layout (location = 0) in vec2 a_position; + // Instance + layout (location = 1) in vec4 a_line; // (x1, y1, x2, y2) + layout (location = 2) in vec4 a_color; + layout (location = 3) in vec2 a_properties; // (thickness, hardness) + + out vec2 vf_localPosition; + out vec4 vf_line; + out vec4 vf_color; + out vec2 vf_properties; + + void main() { + float thickness = a_properties.x; + + vec2 from = a_line.xy; + vec2 to = a_line.zw; + vec2 direction = normalize(to - from); + if (to == from) + direction = vec2(1.0, 0.0); + + // Extrude forward for caps + from -= direction * (thickness / 2.0); + to += direction * (thickness / 2.0); + + vec2 xAxis = to - from; + vec2 yAxis = vec2(-direction.y, direction.x) * thickness; + + vec2 localPosition = from + xAxis * a_position.x + yAxis * a_position.y; + vec4 screenPosition = vec4(localPosition + u_translation, 0.0, 1.0); + vec4 scenePosition = screenPosition * u_projection; + + gl_Position = scenePosition; + vf_localPosition = localPosition; + vf_line = a_line; + vf_color = a_color; + vf_properties = a_properties; + } +`; + +const linesFragmentShader = `#version 300 es + precision highp float; + + in vec2 vf_localPosition; + in vec4 vf_line; + in vec4 vf_color; + in vec2 vf_properties; + + out vec4 f_color; + + // https://iquilezles.org/articles/distfunctions2d/ + + float segmentSdf(vec2 uv, vec2 a, vec2 b) { + vec2 uva = uv - a; + vec2 ba = b - a; + float h = clamp(dot(uva, ba) / dot(ba, ba), 0.0, 1.0); + return length(uva - ba * h); + } + + void main() { + float thickness = vf_properties.x; + float hardness = vf_properties.y; + float halfSoftness = (1.0 - hardness) / 2.0; + + vec2 uv = vf_localPosition; + float alpha = -(segmentSdf(uv, vf_line.xy, vf_line.zw) - thickness) / thickness; + if (hardness > 0.999) + alpha = step(0.5, alpha); + else + alpha = smoothstep(0.5 - halfSoftness, 0.5001 + halfSoftness, alpha); + + f_color = vec4(vec3(1.0), alpha) * vf_color; + } +`; + +const linesMaxInstances = 1; +const lineInstanceSize = 12; +const lineDataBufferSize = lineInstanceSize * linesMaxInstances; + +export class BrushRenderer { + #translation = { x: 0, y: 0 }; + + constructor(gl, canvasSource) { + this.gl = gl; + this.canvasSource = canvasSource; + + console.group("construct BrushRenderer"); + + // Lines + + let linesProgramId = compileProgram(gl, linesVertexShader, linesFragmentShader); + this.linesProgram = { + id: linesProgramId, + u_projection: gl.getUniformLocation(linesProgramId, "u_projection"), + u_translation: gl.getUniformLocation(linesProgramId, "u_translation"), + }; + + this.linesVao = gl.createVertexArray(); + this.linesVbo = gl.createBuffer(); + + gl.bindVertexArray(this.linesVao); + gl.bindBuffer(gl.ARRAY_BUFFER, this.linesVbo); + + const lineRect = new Float32Array( + // prettier-ignore + [ + 0, -0.5, + 1, -0.5, + 0, 0.5, + 1, -0.5, + 1, 0.5, + 0, 0.5, + ], + ); + + this.linesVboData = new Float32Array(lineRect.length + lineDataBufferSize); + this.linesVboData.set(lineRect, 0); + this.linesInstanceData = this.linesVboData.subarray(lineRect.length); + + gl.bufferData(gl.ARRAY_BUFFER, this.linesVboData, gl.DYNAMIC_DRAW, 0); + + gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 2 * 4, 0); // a_position + gl.vertexAttribPointer(1, 4, gl.FLOAT, false, lineInstanceSize, lineRect.byteLength); // a_line + gl.vertexAttribPointer( + 2, // a_color + 4, + gl.FLOAT, + false, + lineInstanceSize, + lineRect.byteLength + 4 * 4, + ); + gl.vertexAttribPointer( + 3, // a_properties + 2, + gl.FLOAT, + false, + lineInstanceSize, + lineRect.byteLength + 4 * 4 * 2, + ); + for (let i = 0; i < 4; ++i) gl.enableVertexAttribArray(i); + for (let i = 1; i < 4; ++i) gl.vertexAttribDivisor(i, 1); + + console.debug("pipeline lines", { + linesVao: this.linesVao, + linesVbo: this.linesVbo, + linesVboSize: this.linesVboData.byteLength, + linesInstanceDataOffset: this.linesInstanceData.byteOffset, + }); + + gl.bindVertexArray(null); + + console.groupEnd(); + } + + #drawLines(instanceCount) { + let gl = this.gl; + + gl.bindVertexArray(this.linesVao); + gl.bindBuffer(gl.ARRAY_BUFFER, this.linesVbo); + gl.bufferSubData( + gl.ARRAY_BUFFER, + this.linesInstanceData.byteOffset, + this.linesInstanceData.subarray(0, instanceCount * lineInstanceSize), + ); + gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, instanceCount); + } + + setTranslation(x, y) { + this.#translation.x = x; + this.#translation.y = y; + } + + stroke(canvas, r, g, b, a, thickness, x1, y1, x2, y2) { + let gl = this.gl; + + let viewport = this.canvasSource.useCanvas(gl, canvas); + + gl.useProgram(this.linesProgram.id); + gl.uniformMatrix4fv( + this.linesProgram.u_projection, + false, + orthographicProjection(0, viewport.width, viewport.height, 0, -1, 1), + ); + gl.uniform2f(this.linesProgram.u_translation, this.#translation.x, this.#translation.y); + + gl.enable(gl.BLEND); + + let instances = this.linesInstanceData; + instances[0] = x1; + instances[1] = y1; + instances[2] = x2; + instances[3] = y2; + instances[4] = r / 255; + instances[5] = g / 255; + instances[6] = b / 255; + instances[7] = a / 255; + instances[8] = thickness; + instances[9] = 1; // hardness + this.#drawLines(1); + + this.canvasSource.resetCanvas(gl); + + return true; + } +} diff --git a/static/canvas-renderer.js b/static/canvas-renderer.js index 8c934df..5e5d25a 100644 --- a/static/canvas-renderer.js +++ b/static/canvas-renderer.js @@ -1,6 +1,9 @@ import { listen, Pool } from "rkgk/framework.js"; import { Viewport } from "rkgk/viewport.js"; import { Wall, chunkKey } from "rkgk/wall.js"; +import { AtlasAllocator } from "rkgk/chunk-allocator.js"; +import { compileProgram } from "rkgk/webgl.js"; +import { BrushRenderer } from "rkgk/brush-renderer.js"; class CanvasRenderer extends HTMLElement { viewport = new Viewport(); @@ -51,6 +54,7 @@ class CanvasRenderer extends HTMLElement { } getWindowSize() { + if (this.width == null || this.height == null) return { width: 0, height: 0 }; return { width: this.width, height: this.height, @@ -73,14 +77,11 @@ class CanvasRenderer extends HTMLElement { // Renderer initialization #initializeRenderer() { - console.groupCollapsed("initializeRenderer"); + console.group("initializeRenderer"); console.info("vendor", this.gl.getParameter(this.gl.VENDOR)); console.info("renderer", this.gl.getParameter(this.gl.RENDERER)); - this.gl.enable(this.gl.BLEND); - this.gl.blendFunc(this.gl.SRC_ALPHA, this.gl.ONE_MINUS_SRC_ALPHA); - // Due to an ANGLE bug on Windows, we can only render around 64 rectangles in a batch. // // It seems that for DirectX it generates a horribly inefficient shader that the DirectX @@ -92,7 +93,9 @@ class CanvasRenderer extends HTMLElement { // We also realistically don't need anymore, because (at least at the time I'm writing this) // we store (8 * 8 = 64) chunks per texture atlas, so we can't batch more than that. const maxRects = 64; - let renderChunksProgramId = this.#compileProgram( + let renderChunksProgramId = compileProgram( + this.gl, + // Vertex `#version 300 es @@ -130,12 +133,26 @@ class CanvasRenderer extends HTMLElement { precision highp float; uniform sampler2D u_texture; + uniform int u_visAtlasIndex; in vec2 vf_uv; out vec4 f_color; + float goldNoise(vec2 xy, float seed) { + return fract(tan(distance(xy * 1.6180339, xy) * seed) * xy.x); + } + void main() { - f_color = texture(u_texture, vf_uv); + vec4 color = texture(u_texture, vf_uv); + if (u_visAtlasIndex != 0) { + color = vec4( + goldNoise(vec2(float(u_visAtlasIndex), 0.0), 0.1), + goldNoise(vec2(float(u_visAtlasIndex), 0.0), 0.2), + goldNoise(vec2(float(u_visAtlasIndex), 0.0), 0.3), + 1.0 + ); + } + f_color = color; } `, ); @@ -146,6 +163,7 @@ class CanvasRenderer extends HTMLElement { u_projection: this.gl.getUniformLocation(renderChunksProgramId, "u_projection"), u_view: this.gl.getUniformLocation(renderChunksProgramId, "u_view"), u_texture: this.gl.getUniformLocation(renderChunksProgramId, "u_texture"), + u_visAtlasIndex: this.gl.getUniformLocation(renderChunksProgramId, "u_visAtlasIndex"), ub_rects: this.gl.getUniformBlockIndex(renderChunksProgramId, "ub_rects"), }; @@ -191,60 +209,32 @@ class CanvasRenderer extends HTMLElement { uboRects: this.uboRects, }); - this.atlasAllocator = new AtlasAllocator(this.wall.chunkSize, 8); - this.chunkAllocations = new Map(); + this.atlasAllocator = new AtlasAllocator(this.gl, this.wall.chunkSize, 8); console.debug("initialized atlas allocator", this.atlasAllocator); this.batches = []; this.batchPool = new Pool(); + this.brushRenderer = new BrushRenderer(this.gl, this.atlasAllocator.canvasSource()); + console.debug("GL error state", this.gl.getError()); console.groupEnd(); - } - #compileShader(kind, source) { - let shader = this.gl.createShader(kind); - - this.gl.shaderSource(shader, source); - this.gl.compileShader(shader); - - if (!this.gl.getShaderParameter(shader, this.gl.COMPILE_STATUS)) { - let error = new Error(`failed to compile shader: ${this.gl.getShaderInfoLog(shader)}`); - this.gl.deleteShader(shader); - throw error; - } else { - return shader; - } - } - - #compileProgram(vertexSource, fragmentSource) { - let vertexShader = this.#compileShader(this.gl.VERTEX_SHADER, vertexSource); - let fragmentShader = this.#compileShader(this.gl.FRAGMENT_SHADER, fragmentSource); - - let program = this.gl.createProgram(); - this.gl.attachShader(program, vertexShader); - this.gl.attachShader(program, fragmentShader); - this.gl.linkProgram(program); - - this.gl.deleteShader(vertexShader); - this.gl.deleteShader(fragmentShader); - - if (!this.gl.getProgramParameter(program, this.gl.LINK_STATUS)) { - let error = new Error(`failed to link program: ${this.gl.getProgramInfoLog(program)}`); - this.gl.deleteProgram(program); - throw error; - } else { - return program; - } + // Flag that prevents the renderer from exploding in case any part of + // initialisation throws an exception. + this.ok = true; } // Renderer #render() { + if (!this.ok) return; + // NOTE: We should probably render on-demand only when it's needed. requestAnimationFrame(() => this.#render()); + this.atlasAllocator.tickDownloads(); this.#renderWall(); } @@ -255,6 +245,10 @@ class CanvasRenderer extends HTMLElement { } this.gl.viewport(0, 0, this.canvas.width, this.canvas.height); + this.gl.scissor(0, 0, this.canvas.width, this.canvas.height); + + this.gl.enable(this.gl.BLEND); + this.gl.blendFunc(this.gl.SRC_ALPHA, this.gl.ONE_MINUS_SRC_ALPHA); this.gl.clearColor(1, 1, 1, 1); this.gl.clear(this.gl.COLOR_BUFFER_BIT); @@ -298,16 +292,14 @@ class CanvasRenderer extends HTMLElement { for (let batch of this.batches) { for (let [i, chunks] of batch) { let atlas = this.atlasAllocator.atlases[i]; - this.gl.bindTexture(this.gl.TEXTURE_2D, atlas.id); + this.gl.bindTexture(this.gl.TEXTURE_2D, atlas.texture); + // this.gl.uniform1i(this.renderChunksProgram.u_visAtlasIndex, i + 1); this.#resetRectBuffer(); for (let chunk of chunks) { - let { i, allocation } = this.getChunkAllocation( - chunk.layerId, - chunk.x, - chunk.y, - ); - let atlas = this.atlasAllocator.atlases[i]; + let atlasIndex = this.atlasAllocator.getAtlasIndex(chunk.id); + let allocation = this.atlasAllocator.getAllocation(chunk.id); + let atlas = this.atlasAllocator.atlases[atlasIndex]; this.#pushRect( chunk.x * this.wall.chunkSize, chunk.y * this.wall.chunkSize, @@ -326,42 +318,20 @@ class CanvasRenderer extends HTMLElement { // TODO: This is a nice debug view. // There should be a switch to it somewhere in the app. /* - let x = 0; - let y = 0; - for (let atlas of this.atlasAllocator.atlases) { - this.#resetRectBuffer(); - this.gl.bindTexture(this.gl.TEXTURE_2D, atlas.id); - this.#pushRect(x, y, atlas.textureSize, atlas.textureSize, 0, 0, 1, 1); - this.#drawRects(); - if (x > atlas.textureSize * 16) { - y += atlas.textureSize; - x = 0; - } - x += atlas.textureSize; - } - */ - } - - getChunkAllocation(layerId, chunkX, chunkY) { - let key = `${layerId}/${chunkKey(chunkX, chunkY)}`; - if (this.chunkAllocations.has(key)) { - return this.chunkAllocations.get(key); - } else { - let allocation = this.atlasAllocator.alloc(this.gl); - this.chunkAllocations.set(key, allocation); - return allocation; - } - } - - deallocateChunks(layer) { - for (let chunkKey of layer.chunks.keys()) { - let key = `${layer.id}/${chunkKey}`; - if (this.chunkAllocations.has(key)) { - let allocation = this.chunkAllocations.get(key); - this.atlasAllocator.dealloc(allocation); - this.chunkAllocations.delete(key); + let x = 0; + let y = 0; + for (let atlas of this.atlasAllocator.atlases) { + this.#resetRectBuffer(); + this.gl.bindTexture(this.gl.TEXTURE_2D, atlas.texture); + this.#pushRect(x, y, atlas.textureSize, atlas.textureSize, 0, 0, 1, 1); + this.#drawRects(); + if (x > atlas.textureSize * 16) { + y += atlas.textureSize; + x = 0; } + x += atlas.textureSize; } + // */ } #collectChunksThisFrame() { @@ -383,20 +353,14 @@ class CanvasRenderer extends HTMLElement { for (let chunkX = left; chunkX < right; ++chunkX) { let chunk = layer.getChunk(chunkX, chunkY); if (chunk != null) { - if (chunk.renderDirty) { - this.#updateChunkTexture(layer, chunkX, chunkY); - chunk.renderDirty = false; - } - - let allocation = this.getChunkAllocation(layer.id, chunkX, chunkY); - - let array = batch.get(allocation.i); + let atlasIndex = this.atlasAllocator.getAtlasIndex(chunk.id); + let array = batch.get(atlasIndex); if (array == null) { array = []; - batch.set(allocation.i, array); + batch.set(atlasIndex, array); } - array.push({ layerId: layer.id, x: chunkX, y: chunkY }); + array.push({ layerId: layer.id, x: chunkX, y: chunkY, id: chunk.id }); } } } @@ -438,12 +402,6 @@ class CanvasRenderer extends HTMLElement { this.gl.drawArraysInstanced(this.gl.TRIANGLES, 0, 6, this.uboRectsNum); } - #updateChunkTexture(layer, chunkX, chunkY) { - let allocation = this.getChunkAllocation(layer.id, chunkX, chunkY); - let chunk = layer.getChunk(chunkX, chunkY); - this.atlasAllocator.upload(this.gl, allocation, chunk.pixmap); - } - // Behaviours sendViewportUpdate() { @@ -584,101 +542,3 @@ class InteractEvent extends Event { } } } - -class Atlas { - static getInitBuffer(chunkSize, nChunks) { - let imageSize = chunkSize * nChunks; - return new Uint8Array(imageSize * imageSize * 4); - } - - constructor(gl, chunkSize, nChunks, initBuffer) { - this.id = gl.createTexture(); - this.chunkSize = chunkSize; - this.nChunks = nChunks; - this.textureSize = chunkSize * nChunks; - - this.free = Array(nChunks * nChunks); - for (let y = 0; y < nChunks; ++y) { - for (let x = 0; x < nChunks; ++x) { - this.free[x + y * nChunks] = { x, y }; - } - } - - gl.bindTexture(gl.TEXTURE_2D, this.id); - gl.texImage2D( - gl.TEXTURE_2D, - 0, - gl.RGBA8, - this.textureSize, - this.textureSize, - 0, - gl.RGBA, - gl.UNSIGNED_BYTE, - initBuffer, - ); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); - } - - alloc() { - return this.free.pop(); - } - - dealloc(xy) { - this.free.push(xy); - } - - upload(gl, { x, y }, pixmap) { - gl.bindTexture(gl.TEXTURE_2D, this.id); - gl.texSubImage2D( - gl.TEXTURE_2D, - 0, - x * this.chunkSize, - y * this.chunkSize, - this.chunkSize, - this.chunkSize, - gl.RGBA, - gl.UNSIGNED_BYTE, - pixmap.getArrayBuffer(), - ); - } -} - -class AtlasAllocator { - atlases = []; - - constructor(chunkSize, nChunks) { - this.chunkSize = chunkSize; - this.nChunks = nChunks; - this.initBuffer = Atlas.getInitBuffer(chunkSize, nChunks); - } - - alloc(gl) { - // Right now we do a dumb linear scan through all atlases, but in the future it would be - // really nice to optimize this by storing information about which atlases have free slots - // precisely. - - for (let i = 0; i < this.atlases.length; ++i) { - let atlas = this.atlases[i]; - let allocation = atlas.alloc(); - if (allocation != null) { - return { i, allocation }; - } - } - - let i = this.atlases.length; - let atlas = new Atlas(gl, this.chunkSize, this.nChunks, this.initBuffer); - let allocation = atlas.alloc(); - this.atlases.push(atlas); - return { i, allocation }; - } - - dealloc({ i, allocation }) { - let atlas = this.atlases[i]; - atlas.dealloc(allocation); - } - - upload(gl, { i, allocation }, pixmap) { - this.atlases[i].upload(gl, allocation, pixmap); - } -} diff --git a/static/chunk-allocator.js b/static/chunk-allocator.js new file mode 100644 index 0000000..12c60e5 --- /dev/null +++ b/static/chunk-allocator.js @@ -0,0 +1,446 @@ +import { compileProgram } from "rkgk/webgl.js"; + +class Atlas { + static getInitBuffer(chunkSize, nChunks) { + let imageSize = chunkSize * nChunks; + return new Uint8Array(imageSize * imageSize * 4).fill(0x00); + } + + constructor(gl, chunkSize, nChunks, initBuffer) { + this.chunkSize = chunkSize; + this.nChunks = nChunks; + this.textureSize = chunkSize * nChunks; + + this.free = Array(nChunks * nChunks); + for (let y = 0; y < nChunks; ++y) { + for (let x = 0; x < nChunks; ++x) { + this.free[x + y * nChunks] = { x, y }; + } + } + + this.texture = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_2D, this.texture); + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.RGBA8, + this.textureSize, + this.textureSize, + 0, + gl.RGBA, + gl.UNSIGNED_BYTE, + initBuffer, + ); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); + + this.framebuffer = gl.createFramebuffer(); + gl.bindFramebuffer(gl.FRAMEBUFFER, this.framebuffer); + gl.framebufferTexture2D( + gl.FRAMEBUFFER, + gl.COLOR_ATTACHMENT0, + gl.TEXTURE_2D, + this.texture, + 0, + ); + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + } + + alloc(gl, initBuffer) { + let xy = this.free.pop(); + if (xy != null) { + this.upload(gl, xy, initBuffer); + } + return xy; + } + + dealloc(xy) { + this.free.push(xy); + } + + upload(gl, { x, y }, source) { + gl.bindTexture(gl.TEXTURE_2D, this.texture); + gl.texSubImage2D( + gl.TEXTURE_2D, + 0, + x * this.chunkSize, + y * this.chunkSize, + this.chunkSize, + this.chunkSize, + gl.RGBA, + gl.UNSIGNED_BYTE, + source, + ); + } + + // Assumes a pack PBO is bound. + download(gl, { x, y }) { + gl.bindFramebuffer(gl.FRAMEBUFFER, this.framebuffer); + gl.readPixels( + x * this.chunkSize, + y * this.chunkSize, + this.chunkSize, + this.chunkSize, + gl.RGBA, + gl.UNSIGNED_BYTE, + 0, + ); + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + } + + getFramebufferRect({ x, y }) { + return { + x: x * this.chunkSize, + y: y * this.chunkSize, + width: this.chunkSize, + height: this.chunkSize, + }; + } +} + +const compositeVertexShader = `#version 300 es + precision highp float; + + layout (location = 0) in vec2 a_position; + layout (location = 1) in vec2 a_uv; + + out vec2 vf_uv; + + void main() { + gl_Position = vec4(a_position, 0.0, 1.0); + vf_uv = a_uv; + } +`; + +const compositeFragmentShader = `#version 300 es + precision highp float; + + uniform sampler2D u_chunk; + + in vec2 vf_uv; + + out vec4 f_color; + + void main() { + f_color = texture(u_chunk, vf_uv); + // f_color = vec4(vec3(0.0), 1.0); + } +`; + +export class AtlasAllocator { + atlases = []; + + // Allocation names + #ids = new Map(); + #idCounter = 1; + + // Download buffers + #pboPool = []; + #downloadBufferPool = []; + #pendingDownloads = []; + + constructor(gl, chunkSize, nChunks) { + this.gl = gl; + this.chunkSize = chunkSize; + this.nChunks = nChunks; + this.initBuffer = Atlas.getInitBuffer(chunkSize, nChunks); + + // Compositing pipeline + + let compositeProgramId = compileProgram(gl, compositeVertexShader, compositeFragmentShader); + this.compositeProgram = { + id: compositeProgramId, + u_chunk: gl.getUniformLocation(compositeProgramId, "u_chunk"), + }; + + // prettier-ignore + this.compositeRectData = new Float32Array([ + // a_position + -1, 1, // 0: top left + 1, 1, // 1: top right + 1, -1, // 2: bottom right + -1, -1, // 3: bottom left + + // a_uv - filled out later when compositing + 0, 0, + 0, 0, + 0, 0, + 0, 0, + ]); + let compositeRectIndices = new Uint16Array([0, 1, 2, 2, 3, 0]); + this.compositeRectUv = this.compositeRectData.subarray(8); + + this.compositeRectVao = gl.createVertexArray(); + this.compositeRectVbo = gl.createBuffer(); + this.compositeRectIbo = gl.createBuffer(); + gl.bindVertexArray(this.compositeRectVao); + + gl.bindBuffer(gl.ARRAY_BUFFER, this.compositeRectVbo); + gl.bufferData(gl.ARRAY_BUFFER, this.compositeRectData, gl.DYNAMIC_DRAW); + console.log(this.compositeRectData.byteLength); + + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.compositeRectIbo); + gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, compositeRectIndices, gl.DYNAMIC_DRAW); + + gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 2 * 4, 0); + gl.vertexAttribPointer(1, 2, gl.FLOAT, false, 2 * 4, this.compositeRectUv.byteOffset); + for (let i = 0; i < 2; ++i) gl.enableVertexAttribArray(i); + + this.compositeTexture = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_2D, this.compositeTexture); + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.RGBA8, + chunkSize, + chunkSize, + 0, + gl.RGBA, + gl.UNSIGNED_BYTE, + null, + ); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); + + this.compositeFramebuffer = gl.createFramebuffer(); + gl.bindFramebuffer(gl.FRAMEBUFFER, this.compositeFramebuffer); + gl.framebufferTexture2D( + gl.FRAMEBUFFER, + gl.COLOR_ATTACHMENT0, + gl.TEXTURE_2D, + this.compositeTexture, + 0, + ); + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + } + + #obtainId(allocInfo) { + let id = this.#idCounter++; + this.#ids.set(id, allocInfo); + return id; + } + + #releaseId(id) { + this.#ids.delete(id); + } + + #getAllocInfo(id) { + return this.#ids.get(id); + } + + getAtlasIndex(id) { + return this.#getAllocInfo(id).i; + } + + getAllocation(id) { + return this.#getAllocInfo(id).allocation; + } + + alloc() { + // Right now we do a dumb linear scan through all atlases, but in the future it would be + // really nice to optimize this by storing information about which atlases have free slots + // precisely. + + for (let i = 0; i < this.atlases.length; ++i) { + let atlas = this.atlases[i]; + let allocation = atlas.alloc(this.gl, this.initBuffer); + if (allocation != null) { + return this.#obtainId({ i, allocation }); + } + } + + let i = this.atlases.length; + let atlas = new Atlas(this.gl, this.chunkSize, this.nChunks, this.initBuffer); + let allocation = atlas.alloc(this.gl, this.initBuffer); + this.atlases.push(atlas); + + return this.#obtainId({ i, allocation }); + } + + dealloc(id) { + let { i, allocation } = this.#getAllocInfo(id); + let atlas = this.atlases[i]; + atlas.dealloc(allocation); + this.#releaseId(id); + } + + upload(id, source) { + let { i, allocation } = this.#getAllocInfo(id); + this.atlases[i].upload(this.gl, allocation, source); + } + + async download(id) { + let gl = this.gl; + let allocInfo = this.#getAllocInfo(id); + + // Get PBO + + let pbo = this.#pboPool.pop(); + if (pbo == null) { + let dataSize = this.chunkSize * this.chunkSize * 4; + pbo = gl.createBuffer(); + gl.bindBuffer(gl.PIXEL_PACK_BUFFER, pbo); + gl.bufferData(gl.PIXEL_PACK_BUFFER, dataSize, gl.DYNAMIC_READ); + } + + // Initiate download + + gl.bindBuffer(gl.PIXEL_PACK_BUFFER, pbo); + this.atlases[allocInfo.i].download(gl, allocInfo.allocation); + let fence = gl.fenceSync(gl.SYNC_GPU_COMMANDS_COMPLETE, 0); + + gl.bindBuffer(gl.PIXEL_PACK_BUFFER, null); + + // Add for ticking + + return new Promise((resolve) => { + this.#pendingDownloads.push({ pbo, fence, resolve }); + }); + } + + // Call after download() finishes running to give memory back to the allocator, for reuse in + // later pixel transfers. + freeDownload(arrayBuffer) { + this.#downloadBufferPool.push(arrayBuffer); + } + + // Call every frame to poll for download completion. + tickDownloads() { + if (this.#pendingDownloads.length == 0) return; + + let gl = this.gl; + + for (let i = 0; i < this.#pendingDownloads.length; ++i) { + let pending = this.#pendingDownloads[i]; + let status = gl.getSyncParameter(pending.fence, gl.SYNC_STATUS); + if (status == gl.SIGNALED) { + // Transfer complete, fetch pixels back to an array buffer. + let dataSize = this.chunkSize * this.chunkSize * 4; + let arrayBuffer = this.#downloadBufferPool.pop() ?? new ArrayBuffer(dataSize); + + gl.bindBuffer(gl.PIXEL_PACK_BUFFER, pending.pbo); + gl.getBufferSubData(gl.PIXEL_PACK_BUFFER, 0, new Uint8Array(arrayBuffer)); + gl.bindBuffer(gl.PIXEL_PACK_BUFFER, null); + gl.deleteSync(pending.fence); + + pending.resolve({ + width: this.chunkSize, + height: this.chunkSize, + data: arrayBuffer, + }); + + let last = this.#pendingDownloads.pop(); + if (this.#pendingDownloads.length > 0) { + this.#pendingDownloads[i] = last; + --i; + } else { + break; // now empty + } + } + } + } + + canvasSource() { + let useCanvas = (gl, id) => { + let allocInfo = this.#getAllocInfo(id); + let atlas = this.atlases[allocInfo.i]; + + let viewport = atlas.getFramebufferRect(allocInfo.allocation); + + gl.enable(gl.SCISSOR_TEST); + gl.bindFramebuffer(gl.FRAMEBUFFER, atlas.framebuffer); + gl.viewport(viewport.x, viewport.y, viewport.width, viewport.height); + gl.scissor(viewport.x, viewport.y, viewport.width, viewport.height); + + return viewport; + }; + + let resetCanvas = (gl) => { + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + gl.disable(gl.SCISSOR_TEST); + }; + + return { + useCanvas, + resetCanvas, + }; + } + + // NOTE: I was thinking a bit about whether the chunk allocator is the right place to put + // compositing operations like this. After much consideration, I've decided that it's pretty + // much the only sensible place, because it's the only place concerned with the layout of + // chunks in memory, and rendering of laid out chunks is quite implementation dependent. + // + // This does break the purity of the "allocator" role a bit though, but I don't really know if + // there's a good way around that. + // + // Maybe. But I don't feel like breaking this apart to 10 smaller classes, either. + + #drawComposite(u, v, uvScale) { + // Assumes bound source texture, destination framebuffer, and viewport set. + + let gl = this.gl; + + gl.bindVertexArray(this.compositeRectVao); + gl.bindBuffer(gl.ARRAY_BUFFER, this.compositeRectVbo); + gl.useProgram(this.compositeProgram.id); + + gl.uniform1i(this.compositeProgram.u_chunk, 0); + + let uv = this.compositeRectUv; + uv[0] = u * uvScale; + uv[1] = v * uvScale; + uv[2] = (u + 1) * uvScale; + uv[3] = v * uvScale; + uv[4] = (u + 1) * uvScale; + uv[5] = (v + 1) * uvScale; + uv[6] = u * uvScale; + uv[7] = (v + 1) * uvScale; + gl.bufferSubData(gl.ARRAY_BUFFER, uv.byteOffset, uv); + + gl.drawElements(gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, 0); + } + + composite(dstId, srcId, op) { + // NOTE: This has to go through an intermediate buffer in case the source and destination + // atlas are the same. + + console.assert(op == "alphaBlend", "composite operation must be alphaBlend"); + + let gl = this.gl; + + let dstAtlas = this.atlases[this.getAtlasIndex(dstId)]; + let srcAtlas = this.atlases[this.getAtlasIndex(srcId)]; + let dstAllocation = this.getAllocation(dstId); + let srcAllocation = this.getAllocation(srcId); + + // Source -> intermediate buffer + + gl.disable(gl.BLEND); + gl.disable(gl.SCISSOR_TEST); + + gl.bindFramebuffer(gl.FRAMEBUFFER, this.compositeFramebuffer); + gl.bindTexture(gl.TEXTURE_2D, srcAtlas.texture); + gl.viewport(0, 0, this.chunkSize, this.chunkSize); + this.#drawComposite(srcAllocation.x, srcAllocation.y, 1 / this.nChunks); + + // Intermediate buffer -> destination + + gl.enable(gl.BLEND); + gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); + + gl.bindFramebuffer(gl.FRAMEBUFFER, dstAtlas.framebuffer); + gl.bindTexture(gl.TEXTURE_2D, this.compositeTexture); + gl.viewport( + dstAllocation.x * this.chunkSize, + dstAllocation.y * this.chunkSize, + this.chunkSize, + this.chunkSize, + ); + this.#drawComposite(0, 0, 1); + + // Cleanup + + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + } +} diff --git a/static/connection-status.js b/static/connection-status.js index e563b04..7f85a44 100644 --- a/static/connection-status.js +++ b/static/connection-status.js @@ -36,11 +36,14 @@ export class ConnectionStatus extends HTMLElement { showError(error) { this.errorDialog.showModal(); + if (typeof error.error == "string") { + this.errorText.value = error.error.toString(); + } if (error instanceof Error) { if (error.stack != null && error.stack != "") { - this.errorText.textContent = `${error.toString()}\n\n${error.stack}`; + this.errorText.value = `${error.toString()}\n\n${error.stack}`; } else { - this.errorText.textContent = error.toString(); + this.errorText.value = error.toString(); } } } diff --git a/static/haku.js b/static/haku.js index a9532d0..be99ed7 100644 --- a/static/haku.js +++ b/static/haku.js @@ -1,11 +1,6 @@ let panicImpl; let logImpl, log2Impl; -let canvasBeginImpl, - canvasLineImpl, - canvasRectangleImpl, - canvasCircleImpl, - canvasFillImpl, - canvasStrokeImpl; +let currentBrushRenderer; function allocCheck(p) { if (p == 0) throw new Error("out of memory"); @@ -47,14 +42,8 @@ let [hakuWasm, haku2Wasm] = await Promise.all([ __haku2_log_info: makeLogFunction2("info"), __haku2_log_debug: makeLogFunction2("debug"), - __haku2_canvas_begin: (c) => canvasBeginImpl(c), - __haku2_canvas_line: (c, x1, y1, x2, y2) => canvasLineImpl(c, x1, y1, x2, y2), - __haku2_canvas_rectangle: (c, x, y, width, height) => - canvasRectangleImpl(c, x, y, width, height), - __haku2_canvas_circle: (c, x, y, r) => canvasCircleImpl(c, x, y, r), - __haku2_canvas_fill: (c, r, g, b, a) => canvasFillImpl(c, r, g, b, a), - __haku2_canvas_stroke: (c, r, g, b, a, thickness) => - canvasStrokeImpl(c, r, g, b, a, thickness), + __haku2_canvas_stroke: (c, r, g, b, a, thickness, x1, y1, x2, y2) => + currentBrushRenderer.stroke(c, r, g, b, a, thickness, x1, y1, x2, y2), }, }), ]); @@ -151,49 +140,8 @@ log2Impl = (level, pScope, scopeLen, pMsg, len) => { ); }; -canvasBeginImpl = w.haku_pixmap_begin; -canvasLineImpl = w.haku_pixmap_line; -canvasRectangleImpl = w.haku_pixmap_rectangle; -canvasCircleImpl = w.haku_pixmap_circle; -canvasFillImpl = w.haku_pixmap_fill; -canvasStrokeImpl = w.haku_pixmap_stroke; - w.haku_init_logging(); -export class Pixmap { - #pPixmap = 0; - - constructor(width, height) { - this.#pPixmap = allocCheck(w.haku_pixmap_new(width, height)); - this.width = width; - this.height = height; - } - - destroy() { - w.haku_pixmap_destroy(this.#pPixmap); - } - - clear(r, g, b, a) { - w.haku_pixmap_clear(this.#pPixmap, r, g, b, a); - } - - get ptr() { - return this.#pPixmap; - } - - getArrayBuffer() { - return new Uint8ClampedArray( - memory.buffer, - w.haku_pixmap_data(this.#pPixmap), - this.width * this.height * 4, - ); - } - - getImageData() { - return new ImageData(this.getArrayBuffer(), this.width, this.height); - } -} - export const ContKind = { Scribble: 0, Dotter: 1, @@ -420,9 +368,12 @@ export class Haku { else return ContKind.Scribble; } - contScribble(pixmap, translationX, translationY) { - w.haku_pixmap_set_translation(pixmap.ptr, translationX, translationY); - let ok = w2.haku2_render(this.#pVm2, pixmap.ptr, this.#renderMaxDepth); + contScribble(renderer, canvas) { + console.assert(currentBrushRenderer == null); + currentBrushRenderer = renderer; + let ok = w2.haku2_render(this.#pVm2, canvas, this.#renderMaxDepth); + currentBrushRenderer = null; + if (!ok) { return this.#exceptionResult(); } else { @@ -449,8 +400,8 @@ export class Haku { while (true) { switch (this.expectedContKind()) { case ContKind.Scribble: - result = await runScribble((pixmap, translationX, translationY) => { - return this.contScribble(pixmap, translationX, translationY); + result = await runScribble((renderer, canvas, translationX, translationY) => { + return this.contScribble(renderer, canvas, translationX, translationY); }); return result; diff --git a/static/index.css b/static/index.css index b9bc61f..6bedf33 100644 --- a/static/index.css +++ b/static/index.css @@ -283,6 +283,8 @@ rkgk-reticle-cursor { rkgk-brush-box { --button-size: 56px; + position: relative; + height: var(--height); padding: 12px; @@ -316,7 +318,7 @@ rkgk-brush-box { border-color: var(--color-brand-blue); } - & > rkgk-brush-preview { + & > .preview { width: var(--button-size); aspect-ratio: 1 / 1; background: none; @@ -352,6 +354,18 @@ rkgk-brush-box { display: flex; } } + + & > canvas { + position: absolute; + left: 0; + top: 0; + width: 100%; + margin: 12px; + + pointer-events: none; + + image-rendering: pixelated; + } } /* Code editor */ diff --git a/static/index.js b/static/index.js index d04c4dc..856c4aa 100644 --- a/static/index.js +++ b/static/index.js @@ -98,6 +98,10 @@ function readUrl(urlString) { }, async onDisconnect() { + if (session.errored) return; // Display the error screen + + console.info("showing disconnected refresh screen"); + let duration = 5000 + Math.random() * 1000; while (true) { console.info("waiting a bit for the server to come back up", duration); @@ -146,6 +150,8 @@ function readUrl(urlString) { } let currentUser = wall.onlineUsers.getUser(session.sessionId); + let chunkAllocator = canvasRenderer.atlasAllocator; + let brushRenderer = canvasRenderer.brushRenderer; // Event loop @@ -187,7 +193,7 @@ function readUrl(urlString) { } if (wallEvent.kind.event == "interact") { - user.simulate(wall, wallEvent.kind.interactions); + user.simulate(chunkAllocator, brushRenderer, wall, wallEvent.kind.interactions); } } }); @@ -214,15 +220,13 @@ function readUrl(urlString) { updatePromises.push( createImageBitmap(blob).then((bitmap) => { let chunk = wall.mainLayer.getOrCreateChunk( + chunkAllocator, info.position.x, info.position.y, ); if (chunk == null) return; - chunk.ctx.globalCompositeOperation = "copy"; - chunk.ctx.drawImage(bitmap, 0, 0); - chunk.syncToPixmap(); - chunk.markModified(); + chunk.upload(chunkAllocator, bitmap); }), ); } @@ -255,18 +259,17 @@ function readUrl(urlString) { let layer = currentUser.getScratchLayer(wall); let result = await currentUser.haku.evalBrush( - selfController(interactionQueue, wall, layer, event), + selfController(interactionQueue, chunkAllocator, brushRenderer, wall, layer, event), ); brushEditor.renderHakuResult(result); }); canvasRenderer.addEventListener(".commitInteraction", async () => { - let scratchLayer = currentUser.commitScratchLayer(wall); + let scratchLayer = currentUser.commitScratchLayer(chunkAllocator, wall); if (scratchLayer == null) return; - canvasRenderer.deallocateChunks(scratchLayer); - let edits = await scratchLayer.toEdits(); - scratchLayer.destroy(); + let edits = await scratchLayer.toEdits(chunkAllocator); + scratchLayer.destroy(chunkAllocator); let editRecords = []; let dataParts = []; @@ -282,7 +285,10 @@ function readUrl(urlString) { cursor += edit.data.size; } - session.sendEdit(editRecords, new Blob(dataParts)); + let data = new Blob(dataParts); + + console.log("sending edit data. record count:", editRecords.length, "data blob:", data); + session.sendEdit(editRecords, data); }); canvasRenderer.addEventListener(".viewportUpdate", () => reticleRenderer.render()); diff --git a/static/online-users.js b/static/online-users.js index 4467107..d73c3c8 100644 --- a/static/online-users.js +++ b/static/online-users.js @@ -42,7 +42,7 @@ export class User { return result; } - simulate(wall, interactions) { + simulate(chunkAllocator, brushRenderer, wall, interactions) { console.group("simulate", this.nickname); for (let interaction of interactions) { if (interaction.kind == "setBrush") { @@ -71,10 +71,17 @@ export class User { if (interaction.kind == "scribble" && this.#expectContKind(ContKind.Scribble)) { renderToChunksInArea( + chunkAllocator, + brushRenderer, this.getScratchLayer(wall), this.simulation.renderArea, - (pixmap, translationX, translationY) => { - return this.haku.contScribble(pixmap, translationX, translationY); + (brushRenderer, canvas, translationX, translationY) => { + return this.haku.contScribble( + brushRenderer, + canvas, + translationX, + translationY, + ); }, ); console.info("ended simulation"); @@ -121,9 +128,9 @@ export class User { // Returns the scratch layer committed to the wall, so that the caller may do additional // processing with the completed layer (i.e. send to the server.) // The layer has to be .destroy()ed once you're done working with it. - commitScratchLayer(wall) { + commitScratchLayer(chunkAllocator, wall) { if (this.scratchLayer != null) { - wall.mainLayer.compositeAlpha(this.scratchLayer); + wall.mainLayer.composite(chunkAllocator, this.scratchLayer, "alphaBlend"); wall.removeLayer(this.scratchLayer); let scratchLayer = this.scratchLayer; this.scratchLayer = null; diff --git a/static/painter.js b/static/painter.js index 4bdfa94..b32bd8f 100644 --- a/static/painter.js +++ b/static/painter.js @@ -22,15 +22,21 @@ function* chunksInRectangle(rect, chunkSize) { } } -export function renderToChunksInArea(layer, renderArea, renderToPixmap) { +export function renderToChunksInArea( + chunkAllocator, + brushRenderer, + layer, + renderArea, + renderToCanvas, +) { for (let [chunkX, chunkY] of chunksInRectangle(renderArea, layer.chunkSize)) { - let chunk = layer.getOrCreateChunk(chunkX, chunkY); + let chunk = layer.getOrCreateChunk(chunkAllocator, chunkX, chunkY); if (chunk == null) continue; let translationX = -chunkX * layer.chunkSize; let translationY = -chunkY * layer.chunkSize; - let result = renderToPixmap(chunk.pixmap, translationX, translationY); - chunk.markModified(); + brushRenderer.setTranslation(translationX, translationY); + let result = renderToCanvas(brushRenderer, chunk.id, translationX, translationY); if (result.status != "ok") return result; } @@ -47,14 +53,26 @@ export function dotterRenderArea(wall, dotter) { }; } -export function selfController(interactionQueue, wall, layer, event) { +export function selfController( + interactionQueue, + chunkAllocator, + brushRenderer, + wall, + layer, + event, +) { let renderArea = null; return { - async runScribble(renderToPixmap) { + async runScribble(renderToCanvas) { interactionQueue.push({ kind: "scribble" }); if (renderArea != null) { - let numChunksToRender = numChunksInRectangle(renderArea, layer.chunkSize); - let result = renderToChunksInArea(layer, renderArea, renderToPixmap); + let result = renderToChunksInArea( + chunkAllocator, + brushRenderer, + layer, + renderArea, + renderToCanvas, + ); return result; } else { console.debug("render area is empty, nothing will be rendered"); diff --git a/static/session.js b/static/session.js index d24c5a7..443a8d8 100644 --- a/static/session.js +++ b/static/session.js @@ -81,6 +81,7 @@ class Session extends EventTarget { super(); this.userId = userId; this.secret = secret; + this.errored = false; } async #recvJson() { @@ -106,6 +107,7 @@ class Session extends EventTarget { } #dispatchError(source, kind, message) { + this.errored = true; this.dispatchEvent( Object.assign(new Event("error"), { source, @@ -123,7 +125,7 @@ class Session extends EventTarget { this.ws.addEventListener("error", (event) => { console.error("WebSocket connection error", error); - this.dispatchEvent(Object.assign(new Event("error"), event)); + this.#dispatchError(event, "ws", "WebSocket connection error"); }); this.ws.addEventListener("message", (event) => { @@ -288,6 +290,7 @@ class Session extends EventTarget { } sendViewport({ left, top, right, bottom }) { + console.trace({ left, top, right, bottom }); this.#sendJson({ request: "viewport", topLeft: { x: left, y: top }, diff --git a/static/wall.js b/static/wall.js index 4c8f846..33da1ed 100644 --- a/static/wall.js +++ b/static/wall.js @@ -1,29 +1,20 @@ -import { Pixmap } from "rkgk/haku.js"; import { OnlineUsers } from "rkgk/online-users.js"; export class Chunk { - constructor(size) { - this.pixmap = new Pixmap(size, size); - this.canvas = new OffscreenCanvas(size, size); - this.ctx = this.canvas.getContext("2d"); - this.renderDirty = false; + constructor(chunkAllocator) { + this.id = chunkAllocator.alloc(); } - destroy() { - this.pixmap.destroy(); + destroy(chunkAllocator) { + chunkAllocator.dealloc(this.id); } - syncFromPixmap() { - this.ctx.putImageData(this.pixmap.getImageData(), 0, 0); + upload(chunkAllocator, source) { + chunkAllocator.upload(this.id, source); } - syncToPixmap() { - let imageData = this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height); - this.pixmap.getImageData().data.set(imageData.data, 0); - } - - markModified() { - this.renderDirty = true; + download(chunkAllocator) { + return chunkAllocator.download(this.id); } } @@ -41,9 +32,9 @@ export class Layer { console.info("created layer", this.id, this.name); } - destroy() { + destroy(chunkAllocator) { for (let { chunk } of this.chunks.values()) { - chunk.destroy(); + chunk.destroy(chunkAllocator); } } @@ -51,41 +42,58 @@ export class Layer { return this.chunks.get(chunkKey(x, y))?.chunk; } - getOrCreateChunk(x, y) { + getOrCreateChunk(chunkAllocator, x, y) { let key = chunkKey(x, y); if (this.chunks.has(key)) { return this.chunks.get(key)?.chunk; } else { if (this.chunkLimit != null && this.chunks.size >= this.chunkLimit) return null; - let chunk = new Chunk(this.chunkSize); + let chunk = new Chunk(chunkAllocator); this.chunks.set(key, { x, y, chunk }); return chunk; } } - compositeAlpha(src) { + composite(chunkAllocator, src, op) { for (let { x, y, chunk: srcChunk } of src.chunks.values()) { - srcChunk.syncFromPixmap(); - let dstChunk = this.getOrCreateChunk(x, y); + let dstChunk = this.getOrCreateChunk(chunkAllocator, x, y); if (dstChunk == null) continue; - dstChunk.ctx.globalCompositeOperation = "source-over"; - dstChunk.ctx.drawImage(srcChunk.canvas, 0, 0); - dstChunk.syncToPixmap(); - dstChunk.markModified(); + chunkAllocator.composite(dstChunk.id, srcChunk.id, op); } } - async toEdits() { + async toEdits(chunkAllocator) { + console.time("toEdits"); + let edits = []; - - let start = performance.now(); - + let encodeTime = 0; for (let { x, y, chunk } of this.chunks.values()) { edits.push({ chunk: { x, y }, - data: chunk.canvas.convertToBlob({ type: "image/png" }), + data: chunk.download(chunkAllocator).then(async (downloaded) => { + let start = performance.now(); + + let imageBitmap = await createImageBitmap( + new ImageData( + new Uint8ClampedArray(downloaded.data), + downloaded.width, + downloaded.height, + ), + ); + chunkAllocator.freeDownload(downloaded.data); + let canvas = new OffscreenCanvas(downloaded.width, downloaded.height); + let ctx = canvas.getContext("bitmaprenderer"); + ctx.transferFromImageBitmap(imageBitmap); + let blob = canvas.convertToBlob({ type: "image/png" }); + + let end = performance.now(); + console.log("encoding image took", end - start, "ms"); + encodeTime += end - start; + + return blob; + }), }); } @@ -93,8 +101,7 @@ export class Layer { edit.data = await edit.data; } - let end = performance.now(); - console.debug("toEdits done", end - start); + console.timeEnd("toEdits"); return edits; } diff --git a/static/webgl.js b/static/webgl.js new file mode 100644 index 0000000..f846408 --- /dev/null +++ b/static/webgl.js @@ -0,0 +1,45 @@ +function compileShader(gl, kind, source) { + let shader = gl.createShader(kind); + + gl.shaderSource(shader, source); + gl.compileShader(shader); + + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + let error = new Error(`failed to compile shader: ${gl.getShaderInfoLog(shader)}`); + gl.deleteShader(shader); + throw error; + } else { + return shader; + } +} + +export function compileProgram(gl, vertexSource, fragmentSource) { + let vertexShader = compileShader(gl, gl.VERTEX_SHADER, vertexSource); + let fragmentShader = compileShader(gl, gl.FRAGMENT_SHADER, fragmentSource); + + let program = gl.createProgram(); + gl.attachShader(program, vertexShader); + gl.attachShader(program, fragmentShader); + gl.linkProgram(program); + + gl.deleteShader(vertexShader); + gl.deleteShader(fragmentShader); + + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + let error = new Error(`failed to link program: ${gl.getProgramInfoLog(program)}`); + gl.deleteProgram(program); + throw error; + } else { + return program; + } +} + +export function orthographicProjection(left, right, top, bottom, near, far) { + // prettier-ignore + return [ + 2 / (right - left), 0, 0, -((right + left) / (right - left)), + 0, 2 / (top - bottom), 0, -((top + bottom) / (top - bottom)), + 0, 0, -2 / (far - near), -((far + near) / (far - near)), + 0, 0, 0, 1, + ]; +}