diff --git a/Cargo.lock b/Cargo.lock index cb1eecc..0ea1625 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -642,6 +642,7 @@ dependencies = [ "haku", "log", "paste", + "tiny-skia", ] [[package]] @@ -885,6 +886,12 @@ 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" @@ -1645,6 +1652,7 @@ checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" dependencies = [ "arrayref", "bytemuck", + "libm", "strict-num", ] diff --git a/Cargo.toml b/Cargo.toml index 8e04f74..5fb2332 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ 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 61ac93e..97006bb 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}} RUST_BACKTRACE=1 cargo run -p rkgk --profile {{profile}} + RKGK_PORT={{port}} RKGK_WASM_PATH=target/wasm32-unknown-unknown/{{wasm_profile}} RUST_LOG={{log}} 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 9b87472..cb4c19c 100644 --- a/crates/haku-wasm/Cargo.toml +++ b/crates/haku-wasm/Cargo.toml @@ -11,6 +11,7 @@ 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 c882252..5030c58 100644 --- a/crates/haku-wasm/src/lib.rs +++ b/crates/haku-wasm/src/lib.rs @@ -16,6 +16,10 @@ 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"))] @@ -197,6 +201,147 @@ 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 aef6348..d75a450 100644 --- a/crates/haku/src/system.rs +++ b/crates/haku/src/system.rs @@ -77,7 +77,13 @@ 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 42d4ec8..cfd32cb 100644 --- a/crates/haku2/src/canvas.zig +++ b/crates/haku2/src/canvas.zig @@ -8,21 +8,41 @@ pub const Canvas = opaque { if (!status) return error.Draw; } - pub fn stroke(c: *Canvas, color: value.Rgba8, thickness: f32, from: value.Vec2, to: value.Vec2) !void { + 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 { const r, const g, const b, const a = color; - try wrap(__haku2_canvas_stroke(c, r, g, b, a, thickness, from[0], from[1], to[0], to[1])); + 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)); } }; -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; +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; diff --git a/crates/haku2/src/render.zig b/crates/haku2/src/render.zig index d9087f5..08e2de4 100644 --- a/crates/haku2/src/render.zig +++ b/crates/haku2/src/render.zig @@ -24,13 +24,18 @@ fn renderRec(vm: *Vm, canvas: *Canvas, val: Value, depth: usize, max_depth: usiz switch (val.ref.*) { .scribble => { - switch (val.ref.scribble) { - .stroke => |stroke| try canvas.stroke( - value.rgbaTo8(stroke.color), - stroke.thickness, - value.vec2From4(stroke.from), - value.vec2From4(stroke.to), - ), + 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)), } }, @@ -41,6 +46,10 @@ 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 e5edbc5..e165031 100644 --- a/crates/haku2/src/system.zig +++ b/crates/haku2/src/system.zig @@ -118,6 +118,14 @@ 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"); @@ -274,7 +282,12 @@ 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) }, }); @@ -725,14 +738,51 @@ fn flatten(list: value.List, cx: Context) Vm.Error!value.Ref { return .{ .list = flattened_list }; } -fn stroke(thickness: f32, color: Rgba, from: Vec4, to: Vec4) value.Ref { +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 { return .{ .scribble = .{ - .stroke = .{ + .shape = shape, + .action = .{ .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 fdb8c47..3269a60 100644 --- a/crates/haku2/src/value.zig +++ b/crates/haku2/src/value.zig @@ -62,6 +62,7 @@ pub const Value = union(enum) { .ref => |r| switch (r.*) { .closure => "function", .list => "list", + .shape => "shape", .scribble => "scribble", .reticle => "reticle", }, @@ -85,7 +86,7 @@ pub const Value = union(enum) { } try writer.writeAll("]"); }, - inline .scribble, .reticle => |x| try writer.print("{}", .{x}), + inline .shape, .scribble, .reticle => |x| try writer.print("{}", .{x}), }, } } @@ -121,6 +122,7 @@ pub fn rgbaTo8(rgba: Rgba) Rgba8 { pub const Ref = union(enum) { closure: Closure, list: List, + shape: Shape, scribble: Scribble, reticle: Reticle, }; @@ -159,14 +161,44 @@ pub const Closure = struct { pub const List = []Value; -pub const Scribble = union(enum) { - stroke: Stroke, +pub const Shape = union(enum) { + point: Vec2, + line: Line, + rect: Rect, + circle: Circle, - pub const Stroke = struct { - thickness: f32, - color: Rgba, - from: Vec4, - to: Vec4, + 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, + }; }; }; diff --git a/crates/rkgk/src/api/wall.rs b/crates/rkgk/src/api/wall.rs index 1f38d1a..4091426 100644 --- a/crates/rkgk/src/api/wall.rs +++ b/crates/rkgk/src/api/wall.rs @@ -94,8 +94,7 @@ 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?).context("LoginRequest")?; + let login_request: LoginRequest = from_message(&recv_expect(ws).await?)?; let user_id = login_request.user; let secret = base64::engine::general_purpose::URL_SAFE .decode(&login_request.secret) @@ -285,8 +284,7 @@ impl SessionLoop { loop { select! { Some(message) = ws.recv() => { - let message = message?; - let request = from_message(&message).context("Request")?; + let request = from_message(&message?)?; self.process_request(ws, request).await?; } diff --git a/crates/rkgk/src/main.rs b/crates/rkgk/src/main.rs index 7189efe..bd87b52 100644 --- a/crates/rkgk/src/main.rs +++ b/crates/rkgk/src/main.rs @@ -93,10 +93,7 @@ async fn main() { #[cfg(feature = "memory-profiling")] let _client = tracy_client::Client::start(); - color_eyre::config::HookBuilder::new() - .theme(color_eyre::config::Theme::new()) - .install() - .unwrap(); + color_eyre::install().unwrap(); tracing_subscriber::registry() .with(tracing_subscriber::fmt::layer()) .with( diff --git a/static/brush-box.js b/static/brush-box.js index 78baefb..bee6be6 100644 --- a/static/brush-box.js +++ b/static/brush-box.js @@ -3,7 +3,6 @@ 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 = [ { @@ -16,7 +15,7 @@ color: #000 thickness: 8 withDotter \\d -> - stroke thickness color d.From d.To + stroke thickness color (line d.From d.To) `.trim(), }, @@ -28,7 +27,7 @@ color: #000 thickness: 48 withDotter \\d -> - stroke thickness color d.From d.To + stroke thickness color (line d.From d.To) `.trim(), }, @@ -44,7 +43,7 @@ duty: 0.5 withDotter \\d -> visible? = d.Num |mod length < length * duty if (visible?) - stroke thickness color d.From d.To + stroke thickness color (line d.From d.To) else () `.trim(), @@ -58,7 +57,7 @@ color: #0003 thickness: 6 withDotter \\d -> - stroke thickness color d.From d.To + stroke thickness color (line d.From d.To) `.trim(), }, @@ -76,7 +75,7 @@ withDotter \\d -> a = sin (d.Num * wavelength / pi) + 1 / 2 range = maxThickness - minThickness thickness = a * range + minThickness - stroke thickness color d.From d.To + stroke thickness color (line d.From d.To) `.trim(), }, @@ -105,7 +104,7 @@ withDotter \\d -> clockwise = norm (perpClockwise d.To-d.From) * vec a a from = d.From + clockwise to = d.To + clockwise - stroke thickness color from to + stroke thickness color (line from to) `.trim(), }, @@ -126,7 +125,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 d.From d.To + stroke thickness color (line d.From d.To) `.trim(), }, @@ -135,8 +134,7 @@ withDotter \\d -> function presetButton(info) { let button = document.createElement("button"); - let preview = button.appendChild(document.createElement("div")); - preview.classList.add("preview"); + let preview = button.appendChild(new BrushPreview(56, 56)); let label = button.appendChild(document.createElement("p")); label.classList.add("label"); label.innerText = info.name; @@ -174,14 +172,6 @@ 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(); } @@ -300,61 +290,10 @@ 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() { - 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(); - + for (let brush of this.brushes) { this.haku.setBrush(brush.preset.code); - 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); - }, - }); + await brush.presetButton.preview.renderBrush(this.haku); } } diff --git a/static/brush-preview.js b/static/brush-preview.js index cca3150..97a2015 100644 --- a/static/brush-preview.js +++ b/static/brush-preview.js @@ -1,3 +1,5 @@ +import { Pixmap } from "rkgk/haku.js"; + export class BrushPreview extends HTMLElement { constructor(width, height) { super(); @@ -7,11 +9,53 @@ export class BrushPreview extends HTMLElement { } connectedCallback() { - // TODO + 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")); } async #renderBrushInner(haku) { - // TODO + 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); + return { status: "ok" }; } diff --git a/static/brush-renderer.js b/static/brush-renderer.js deleted file mode 100644 index 462012e..0000000 --- a/static/brush-renderer.js +++ /dev/null @@ -1,212 +0,0 @@ -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 5e5d25a..8c934df 100644 --- a/static/canvas-renderer.js +++ b/static/canvas-renderer.js @@ -1,9 +1,6 @@ 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(); @@ -54,7 +51,6 @@ class CanvasRenderer extends HTMLElement { } getWindowSize() { - if (this.width == null || this.height == null) return { width: 0, height: 0 }; return { width: this.width, height: this.height, @@ -77,11 +73,14 @@ class CanvasRenderer extends HTMLElement { // Renderer initialization #initializeRenderer() { - console.group("initializeRenderer"); + console.groupCollapsed("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 @@ -93,9 +92,7 @@ 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 = compileProgram( - this.gl, - + let renderChunksProgramId = this.#compileProgram( // Vertex `#version 300 es @@ -133,26 +130,12 @@ 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() { - 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; + f_color = texture(u_texture, vf_uv); } `, ); @@ -163,7 +146,6 @@ 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"), }; @@ -209,32 +191,60 @@ class CanvasRenderer extends HTMLElement { uboRects: this.uboRects, }); - this.atlasAllocator = new AtlasAllocator(this.gl, this.wall.chunkSize, 8); + this.atlasAllocator = new AtlasAllocator(this.wall.chunkSize, 8); + this.chunkAllocations = new Map(); 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(); + } - // Flag that prevents the renderer from exploding in case any part of - // initialisation throws an exception. - this.ok = true; + #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; + } } // 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(); } @@ -245,10 +255,6 @@ 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); @@ -292,14 +298,16 @@ 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.texture); - // this.gl.uniform1i(this.renderChunksProgram.u_visAtlasIndex, i + 1); + this.gl.bindTexture(this.gl.TEXTURE_2D, atlas.id); this.#resetRectBuffer(); for (let chunk of chunks) { - let atlasIndex = this.atlasAllocator.getAtlasIndex(chunk.id); - let allocation = this.atlasAllocator.getAllocation(chunk.id); - let atlas = this.atlasAllocator.atlases[atlasIndex]; + let { i, allocation } = this.getChunkAllocation( + chunk.layerId, + chunk.x, + chunk.y, + ); + let atlas = this.atlasAllocator.atlases[i]; this.#pushRect( chunk.x * this.wall.chunkSize, chunk.y * this.wall.chunkSize, @@ -318,20 +326,42 @@ 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.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; + 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); } - x += atlas.textureSize; } - // */ } #collectChunksThisFrame() { @@ -353,14 +383,20 @@ class CanvasRenderer extends HTMLElement { for (let chunkX = left; chunkX < right; ++chunkX) { let chunk = layer.getChunk(chunkX, chunkY); if (chunk != null) { - let atlasIndex = this.atlasAllocator.getAtlasIndex(chunk.id); - let array = batch.get(atlasIndex); - if (array == null) { - array = []; - batch.set(atlasIndex, array); + if (chunk.renderDirty) { + this.#updateChunkTexture(layer, chunkX, chunkY); + chunk.renderDirty = false; } - array.push({ layerId: layer.id, x: chunkX, y: chunkY, id: chunk.id }); + let allocation = this.getChunkAllocation(layer.id, chunkX, chunkY); + + let array = batch.get(allocation.i); + if (array == null) { + array = []; + batch.set(allocation.i, array); + } + + array.push({ layerId: layer.id, x: chunkX, y: chunkY }); } } } @@ -402,6 +438,12 @@ 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() { @@ -542,3 +584,101 @@ 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 deleted file mode 100644 index 12c60e5..0000000 --- a/static/chunk-allocator.js +++ /dev/null @@ -1,446 +0,0 @@ -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 7f85a44..e563b04 100644 --- a/static/connection-status.js +++ b/static/connection-status.js @@ -36,14 +36,11 @@ 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.value = `${error.toString()}\n\n${error.stack}`; + this.errorText.textContent = `${error.toString()}\n\n${error.stack}`; } else { - this.errorText.value = error.toString(); + this.errorText.textContent = error.toString(); } } } diff --git a/static/haku.js b/static/haku.js index be99ed7..a9532d0 100644 --- a/static/haku.js +++ b/static/haku.js @@ -1,6 +1,11 @@ let panicImpl; let logImpl, log2Impl; -let currentBrushRenderer; +let canvasBeginImpl, + canvasLineImpl, + canvasRectangleImpl, + canvasCircleImpl, + canvasFillImpl, + canvasStrokeImpl; function allocCheck(p) { if (p == 0) throw new Error("out of memory"); @@ -42,8 +47,14 @@ let [hakuWasm, haku2Wasm] = await Promise.all([ __haku2_log_info: makeLogFunction2("info"), __haku2_log_debug: makeLogFunction2("debug"), - __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), + __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), }, }), ]); @@ -140,8 +151,49 @@ 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, @@ -368,12 +420,9 @@ export class Haku { else return ContKind.Scribble; } - contScribble(renderer, canvas) { - console.assert(currentBrushRenderer == null); - currentBrushRenderer = renderer; - let ok = w2.haku2_render(this.#pVm2, canvas, this.#renderMaxDepth); - currentBrushRenderer = null; - + contScribble(pixmap, translationX, translationY) { + w.haku_pixmap_set_translation(pixmap.ptr, translationX, translationY); + let ok = w2.haku2_render(this.#pVm2, pixmap.ptr, this.#renderMaxDepth); if (!ok) { return this.#exceptionResult(); } else { @@ -400,8 +449,8 @@ export class Haku { while (true) { switch (this.expectedContKind()) { case ContKind.Scribble: - result = await runScribble((renderer, canvas, translationX, translationY) => { - return this.contScribble(renderer, canvas, translationX, translationY); + result = await runScribble((pixmap, translationX, translationY) => { + return this.contScribble(pixmap, translationX, translationY); }); return result; diff --git a/static/index.css b/static/index.css index 6bedf33..b9bc61f 100644 --- a/static/index.css +++ b/static/index.css @@ -283,8 +283,6 @@ rkgk-reticle-cursor { rkgk-brush-box { --button-size: 56px; - position: relative; - height: var(--height); padding: 12px; @@ -318,7 +316,7 @@ rkgk-brush-box { border-color: var(--color-brand-blue); } - & > .preview { + & > rkgk-brush-preview { width: var(--button-size); aspect-ratio: 1 / 1; background: none; @@ -354,18 +352,6 @@ 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 856c4aa..d04c4dc 100644 --- a/static/index.js +++ b/static/index.js @@ -98,10 +98,6 @@ 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); @@ -150,8 +146,6 @@ function readUrl(urlString) { } let currentUser = wall.onlineUsers.getUser(session.sessionId); - let chunkAllocator = canvasRenderer.atlasAllocator; - let brushRenderer = canvasRenderer.brushRenderer; // Event loop @@ -193,7 +187,7 @@ function readUrl(urlString) { } if (wallEvent.kind.event == "interact") { - user.simulate(chunkAllocator, brushRenderer, wall, wallEvent.kind.interactions); + user.simulate(wall, wallEvent.kind.interactions); } } }); @@ -220,13 +214,15 @@ 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.upload(chunkAllocator, bitmap); + chunk.ctx.globalCompositeOperation = "copy"; + chunk.ctx.drawImage(bitmap, 0, 0); + chunk.syncToPixmap(); + chunk.markModified(); }), ); } @@ -259,17 +255,18 @@ function readUrl(urlString) { let layer = currentUser.getScratchLayer(wall); let result = await currentUser.haku.evalBrush( - selfController(interactionQueue, chunkAllocator, brushRenderer, wall, layer, event), + selfController(interactionQueue, wall, layer, event), ); brushEditor.renderHakuResult(result); }); canvasRenderer.addEventListener(".commitInteraction", async () => { - let scratchLayer = currentUser.commitScratchLayer(chunkAllocator, wall); + let scratchLayer = currentUser.commitScratchLayer(wall); if (scratchLayer == null) return; - let edits = await scratchLayer.toEdits(chunkAllocator); - scratchLayer.destroy(chunkAllocator); + canvasRenderer.deallocateChunks(scratchLayer); + let edits = await scratchLayer.toEdits(); + scratchLayer.destroy(); let editRecords = []; let dataParts = []; @@ -285,10 +282,7 @@ function readUrl(urlString) { cursor += edit.data.size; } - let data = new Blob(dataParts); - - console.log("sending edit data. record count:", editRecords.length, "data blob:", data); - session.sendEdit(editRecords, data); + session.sendEdit(editRecords, new Blob(dataParts)); }); canvasRenderer.addEventListener(".viewportUpdate", () => reticleRenderer.render()); diff --git a/static/online-users.js b/static/online-users.js index d73c3c8..4467107 100644 --- a/static/online-users.js +++ b/static/online-users.js @@ -42,7 +42,7 @@ export class User { return result; } - simulate(chunkAllocator, brushRenderer, wall, interactions) { + simulate(wall, interactions) { console.group("simulate", this.nickname); for (let interaction of interactions) { if (interaction.kind == "setBrush") { @@ -71,17 +71,10 @@ export class User { if (interaction.kind == "scribble" && this.#expectContKind(ContKind.Scribble)) { renderToChunksInArea( - chunkAllocator, - brushRenderer, this.getScratchLayer(wall), this.simulation.renderArea, - (brushRenderer, canvas, translationX, translationY) => { - return this.haku.contScribble( - brushRenderer, - canvas, - translationX, - translationY, - ); + (pixmap, translationX, translationY) => { + return this.haku.contScribble(pixmap, translationX, translationY); }, ); console.info("ended simulation"); @@ -128,9 +121,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(chunkAllocator, wall) { + commitScratchLayer(wall) { if (this.scratchLayer != null) { - wall.mainLayer.composite(chunkAllocator, this.scratchLayer, "alphaBlend"); + wall.mainLayer.compositeAlpha(this.scratchLayer); wall.removeLayer(this.scratchLayer); let scratchLayer = this.scratchLayer; this.scratchLayer = null; diff --git a/static/painter.js b/static/painter.js index b32bd8f..4bdfa94 100644 --- a/static/painter.js +++ b/static/painter.js @@ -22,21 +22,15 @@ function* chunksInRectangle(rect, chunkSize) { } } -export function renderToChunksInArea( - chunkAllocator, - brushRenderer, - layer, - renderArea, - renderToCanvas, -) { +export function renderToChunksInArea(layer, renderArea, renderToPixmap) { for (let [chunkX, chunkY] of chunksInRectangle(renderArea, layer.chunkSize)) { - let chunk = layer.getOrCreateChunk(chunkAllocator, chunkX, chunkY); + let chunk = layer.getOrCreateChunk(chunkX, chunkY); if (chunk == null) continue; let translationX = -chunkX * layer.chunkSize; let translationY = -chunkY * layer.chunkSize; - brushRenderer.setTranslation(translationX, translationY); - let result = renderToCanvas(brushRenderer, chunk.id, translationX, translationY); + let result = renderToPixmap(chunk.pixmap, translationX, translationY); + chunk.markModified(); if (result.status != "ok") return result; } @@ -53,26 +47,14 @@ export function dotterRenderArea(wall, dotter) { }; } -export function selfController( - interactionQueue, - chunkAllocator, - brushRenderer, - wall, - layer, - event, -) { +export function selfController(interactionQueue, wall, layer, event) { let renderArea = null; return { - async runScribble(renderToCanvas) { + async runScribble(renderToPixmap) { interactionQueue.push({ kind: "scribble" }); if (renderArea != null) { - let result = renderToChunksInArea( - chunkAllocator, - brushRenderer, - layer, - renderArea, - renderToCanvas, - ); + let numChunksToRender = numChunksInRectangle(renderArea, layer.chunkSize); + let result = renderToChunksInArea(layer, renderArea, renderToPixmap); return result; } else { console.debug("render area is empty, nothing will be rendered"); diff --git a/static/session.js b/static/session.js index 443a8d8..d24c5a7 100644 --- a/static/session.js +++ b/static/session.js @@ -81,7 +81,6 @@ class Session extends EventTarget { super(); this.userId = userId; this.secret = secret; - this.errored = false; } async #recvJson() { @@ -107,7 +106,6 @@ class Session extends EventTarget { } #dispatchError(source, kind, message) { - this.errored = true; this.dispatchEvent( Object.assign(new Event("error"), { source, @@ -125,7 +123,7 @@ class Session extends EventTarget { this.ws.addEventListener("error", (event) => { console.error("WebSocket connection error", error); - this.#dispatchError(event, "ws", "WebSocket connection error"); + this.dispatchEvent(Object.assign(new Event("error"), event)); }); this.ws.addEventListener("message", (event) => { @@ -290,7 +288,6 @@ 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 33da1ed..4c8f846 100644 --- a/static/wall.js +++ b/static/wall.js @@ -1,20 +1,29 @@ +import { Pixmap } from "rkgk/haku.js"; import { OnlineUsers } from "rkgk/online-users.js"; export class Chunk { - constructor(chunkAllocator) { - this.id = chunkAllocator.alloc(); + constructor(size) { + this.pixmap = new Pixmap(size, size); + this.canvas = new OffscreenCanvas(size, size); + this.ctx = this.canvas.getContext("2d"); + this.renderDirty = false; } - destroy(chunkAllocator) { - chunkAllocator.dealloc(this.id); + destroy() { + this.pixmap.destroy(); } - upload(chunkAllocator, source) { - chunkAllocator.upload(this.id, source); + syncFromPixmap() { + this.ctx.putImageData(this.pixmap.getImageData(), 0, 0); } - download(chunkAllocator) { - return chunkAllocator.download(this.id); + 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; } } @@ -32,9 +41,9 @@ export class Layer { console.info("created layer", this.id, this.name); } - destroy(chunkAllocator) { + destroy() { for (let { chunk } of this.chunks.values()) { - chunk.destroy(chunkAllocator); + chunk.destroy(); } } @@ -42,58 +51,41 @@ export class Layer { return this.chunks.get(chunkKey(x, y))?.chunk; } - getOrCreateChunk(chunkAllocator, x, y) { + getOrCreateChunk(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(chunkAllocator); + let chunk = new Chunk(this.chunkSize); this.chunks.set(key, { x, y, chunk }); return chunk; } } - composite(chunkAllocator, src, op) { + compositeAlpha(src) { for (let { x, y, chunk: srcChunk } of src.chunks.values()) { - let dstChunk = this.getOrCreateChunk(chunkAllocator, x, y); + srcChunk.syncFromPixmap(); + let dstChunk = this.getOrCreateChunk(x, y); if (dstChunk == null) continue; - chunkAllocator.composite(dstChunk.id, srcChunk.id, op); + dstChunk.ctx.globalCompositeOperation = "source-over"; + dstChunk.ctx.drawImage(srcChunk.canvas, 0, 0); + dstChunk.syncToPixmap(); + dstChunk.markModified(); } } - async toEdits(chunkAllocator) { - console.time("toEdits"); - + async toEdits() { let edits = []; - let encodeTime = 0; + + let start = performance.now(); + for (let { x, y, chunk } of this.chunks.values()) { edits.push({ chunk: { x, y }, - 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; - }), + data: chunk.canvas.convertToBlob({ type: "image/png" }), }); } @@ -101,7 +93,8 @@ export class Layer { edit.data = await edit.data; } - console.timeEnd("toEdits"); + let end = performance.now(); + console.debug("toEdits done", end - start); return edits; } diff --git a/static/webgl.js b/static/webgl.js deleted file mode 100644 index f846408..0000000 --- a/static/webgl.js +++ /dev/null @@ -1,45 +0,0 @@ -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, - ]; -}