From 7122a470259e1b64687b0e2e52c5d7a0d1227c2e Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 5 Oct 2026 05:00:42 -0500 Subject: [PATCH] Compile render cache coordination in TypeScript - Own pipeline, path, image, layer, resource, and effect cache coordination in TypeScript. - Preserve ordered frame failures and native resource lifetimes with shared copied buffers. --- packages/core/src/runtime_policy.ts | 66 +++++ packages/core/test/render_cache.test.ts | 62 +++++ src/primitives/canvas/frame.zig | 22 +- src/primitives/canvas/render.zig | 14 + src/primitives/canvas/render_cache_policy.zig | 136 ++++++++++ src/primitives/canvas/render_effects.zig | 10 + .../canvas/render_generic_resources.zig | 10 + src/primitives/canvas/render_images.zig | 10 + src/primitives/canvas/render_layers.zig | 10 + src/primitives/canvas/render_paths.zig | 10 + src/primitives/canvas/root.zig | 1 + src/runtime/api.zig | 1 + src/runtime/canvas_frame.zig | 22 +- src/runtime/core.zig | 1 + src/runtime/flow.zig | 1 + src/runtime/ts_ui_app.zig | 2 + src/runtime/ui_app.zig | 2 + tests/ts-core/render_cache_e2e_tests.zig | 244 ++++++++++++++++++ tests/ts-core/surface_layout_e2e_tests.zig | 3 + 19 files changed, 615 insertions(+), 12 deletions(-) create mode 100644 packages/core/test/render_cache.test.ts create mode 100644 src/primitives/canvas/render_cache_policy.zig create mode 100644 tests/ts-core/render_cache_e2e_tests.zig diff --git a/packages/core/src/runtime_policy.ts b/packages/core/src/runtime_policy.ts index 4a1d39bc6..638ae3e29 100644 --- a/packages/core/src/runtime_policy.ts +++ b/packages/core/src/runtime_policy.ts @@ -117,6 +117,7 @@ export function native_window_policy(request: Uint8Array): Uint8Array { if (request[0] === 9) return nscvSemanticTree(request); if (request[0] === 10) return nscvExtentPolicy(request); if (request[0] === 11) return nscvTextCachePolicy(request); + if (request[0] === 12) return nscvRenderCachePolicy(request); const data = new DataView(request.buffer, request.byteOffset, request.byteLength); let at = 0; const byte = (): number => { @@ -1574,3 +1575,68 @@ function nscvTextCachePolicy(request: Uint8Array): Uint8Array { for(let i=0;i 5 || request[2] !== 0 || request[3] !== 0) throw new Error("invalid render cache request"); + const input = new DataView(request.buffer, request.byteOffset, request.byteLength); + const current = input.getUint32(4, true), previous = input.getUint32(8, true); + const capacity = input.getUint32(12, true), actionCapacity = input.getUint32(16, true), total = current + previous; + if (total > 4294967295 || request.length !== 24 + total * 56 || input.getUint32(20, true) !== 0) throw new Error("invalid render cache shape"); + for (let i = 0; i < total; i++) if (input.getUint32(24 + i * 56 + 52, true) !== 0) throw new Error("invalid render cache fact"); + const indexed = request[1] === 4 && (current >= 64 || previous >= 64) && current <= 2048 && previous <= 2048; + const entries = new Uint32Array(Math.min(capacity, current)); + const actions = new Uint32Array(Math.min(actionCapacity, total) * 3); + const previousHeads = new Uint32Array(indexed ? 4096 : 0), entryHeads = new Uint32Array(indexed ? 4096 : 0); + const previousNext = new Uint32Array(indexed ? previous : 0), entryNext = new Uint32Array(indexed ? Math.min(capacity, current) : 0); + if (indexed) for (let i = previous - 1; i >= 0; i--) { + const bucket = input.getUint32(24 + (current + i) * 56 + 48, true) & 4095; + previousNext[i] = previousHeads[bucket]!; previousHeads[bucket] = i + 1; + } + const equal = (a: number, b: number): boolean => { + const x = 24 + a * 56, y = 24 + b * 56; + for (let i = 0; i < 48; i++) if (request[x + i] !== request[y + i]) return false; + return true; + }; + let entryCount = 0, actionCount = 0, failed = false; + const lookup = (source: number, old: boolean): number => { + if (indexed) { + const heads = old ? previousHeads : entryHeads, next = old ? previousNext : entryNext; + const bucket = input.getUint32(24 + source * 56 + 48, true) & 4095; + let match = -1; + for (let stored = heads[bucket]!; stored > 0; stored = next[stored - 1]!) { + const i = stored - 1; + if (equal(source, old ? current + i : entries[i]!) && (match < 0 || i < match)) match = i; + } + return match; + } + for (let i = 0; i < (old ? previous : entryCount); i++) if (equal(source, old ? current + i : entries[i]!)) return i; + return -1; + }; + for (let i = 0; i < current; i++) { + if (lookup(i, false) >= 0) continue; + const old = lookup(i, true); + if (actionCount >= actionCapacity) { failed = true; break; } + actions[actionCount * 3] = old < 0 ? 0 : 1; + actions[actionCount * 3 + 1] = i; actions[actionCount * 3 + 2] = old < 0 ? 4294967295 : old; actionCount++; + // Native writes the action first, including when entry capacity then fails. + if (entryCount >= capacity) { failed = true; break; } + entries[entryCount] = i; + if (indexed) { + const bucket = input.getUint32(24 + i * 56 + 48, true) & 4095; + entryNext[entryCount] = entryHeads[bucket]!; entryHeads[bucket] = entryCount + 1; + } + entryCount++; + } + if (!failed) for (let i = 0; i < previous; i++) { + const source = current + i; + if (lookup(source, false) >= 0) continue; + if (actionCount >= actionCapacity) { failed = true; break; } + actions[actionCount * 3] = 2; actions[actionCount * 3 + 1] = source; actions[actionCount * 3 + 2] = i; actionCount++; + } + const result = new Uint8Array(16 + entryCount * 4 + actionCount * 12), output = new DataView(result.buffer); + output.setUint32(0, entryCount, true); output.setUint32(4, actionCount, true); output.setUint32(8, failed ? 1 : 0, true); + for (let i = 0; i < entryCount; i++) output.setUint32(16 + i * 4, entries[i]!, true); + for (let i = 0; i < actionCount * 3; i++) output.setUint32(16 + entryCount * 4 + i * 4, actions[i]!, true); + return result; +} diff --git a/packages/core/test/render_cache.test.ts b/packages/core/test/render_cache.test.ts new file mode 100644 index 000000000..acea793e5 --- /dev/null +++ b/packages/core/test/render_cache.test.ts @@ -0,0 +1,62 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { native_window_policy } from "../src/runtime_policy.ts"; + +type Fact = { key: bigint; position?: bigint; id?: bigint | null; kind?: number; image?: bigint; font?: bigint; bucket?: number }; +function request(family: number, current: Fact[], previous: Fact[], capacity = 100, actionCapacity = 100): Uint8Array { + const r = new Uint8Array(24 + (current.length + previous.length) * 56), w = new DataView(r.buffer); + r[0] = 12; r[1] = family; + [current.length, previous.length, capacity, actionCapacity].forEach((n, i) => w.setUint32(4 + i * 4, n, true)); + [...current, ...previous].forEach((f, i) => { + const at = 24 + i * 56; + w.setUint32(at, f.kind ?? 0, true); w.setUint32(at + 4, f.id == null ? 0 : 1, true); + w.setBigUint64(at + 8, f.id ?? 0n, true); w.setBigUint64(at + 16, f.position ?? 0n, true); + w.setBigUint64(at + 24, f.image ?? 0n, true); w.setBigUint64(at + 32, f.font ?? 0n, true); + w.setBigUint64(at + 40, f.key, true); w.setUint32(at + 48, f.bucket ?? Number(f.key & 4095n), true); + }); + return r; +} +function decode(r: Uint8Array) { + const w = new DataView(r.buffer), n = w.getUint32(0, true), m = w.getUint32(4, true); + return { failed: w.getUint32(8, true), entries: Array.from({ length: n }, (_, i) => w.getUint32(16 + i * 4, true)), actions: Array.from({ length: m }, (_, i) => Array.from({ length: 3 }, (_, j) => w.getUint32(16 + n * 4 + i * 12 + j * 4, true))) }; +} +for (let family = 0; family < 6; family++) { + test(`render cache ${family} preserves ordering, first matches and every unused previous duplicate`, () => { + assert.deepEqual(decode(native_window_policy(request(family, [{ key: 2n }, { key: 2n }, { key: 3n }], [{ key: 2n }, { key: 2n }, { key: 4n }, { key: 4n }]))), { failed: 0, entries: [0, 2], actions: [[1, 0, 0], [0, 2, 4294967295], [2, 5, 2], [2, 6, 3]] }); + }); + test(`render cache ${family} writes actions before entries on capacity failures`, () => { + const c = [{ key: 1n }, { key: 2n }]; + assert.deepEqual(decode(native_window_policy(request(family, c, [], 0, 9))), { failed: 1, entries: [], actions: [[0, 0, 4294967295]] }); + assert.deepEqual(decode(native_window_policy(request(family, c, [], 1, 9))), { failed: 1, entries: [0], actions: [[0, 0, 4294967295], [0, 1, 4294967295]] }); + assert.deepEqual(decode(native_window_policy(request(family, c, [], 9, 1))), { failed: 1, entries: [0], actions: [[0, 0, 4294967295]] }); + assert.deepEqual(decode(native_window_policy(request(family, [], c, 0, 1))), { failed: 1, entries: [], actions: [[2, 0, 0]] }); + }); + test(`render cache ${family} compares every complete key field without uint64 rounding`, () => { + const base: Fact = { key: 9007199254740993n, id: 0n, position: 9007199254740993n, image: 18446744073709551615n, font: 9007199254740997n }; + const facts = [base, { ...base, key: base.key + 1n }, { ...base, id: null }, { ...base, id: 1n }, { ...base, position: base.position! + 1n }, { ...base, image: base.image! - 1n }, { ...base, font: base.font! + 1n }, { ...base, kind: 1 }]; + const out = decode(native_window_policy(request(family, facts, [base]))); + assert.deepEqual(out.entries, facts.map((_, i) => i)); assert.equal(out.actions[0]![0], 1); assert.ok(out.actions.slice(1).every(a => a[0] === 0)); + }); +} +test("generic render resources preserve indexed collisions, threshold and oversized linear parity", () => { + for (const count of [63, 64, 2048, 2049]) { + const c = Array.from({ length: count }, (_, i) => ({ key: BigInt(i >> 1), bucket: 17 })); + const out = decode(native_window_policy(request(4, c, c, count, count * 2))); + assert.equal(out.entries.length, Math.ceil(count / 2)); assert.deepEqual(out.actions.map(a => a[2]), out.entries); + } + const previous = Array.from({ length: 64 }, (_, i) => ({ key: BigInt(i >> 1), bucket: 1 })); + assert.deepEqual(decode(native_window_policy(request(4, [], previous, 0, 64))).actions.map(a => a[2]), previous.map((_, i) => i)); +}); +test("render cache copied results survive later family calls and sliced wire views", () => { + const r = request(4, [{ key: 1n }], []), padded = new Uint8Array(r.length + 9); padded.set(r, 4); + const output = native_window_policy(padded.subarray(4, 4 + r.length)), before = output.slice(); + for (let family = 0; family < 6; family++) native_window_policy(request(family, [], [])); + assert.deepEqual(output, before); assert.equal(decode(output).entries.length, 1); +}); +test("render cache wire refuses malformed shape, reserved fields and discriminators", () => { + const r = request(4, [{ key: 1n }], []); + for (let i = 1; i < r.length; i++) assert.throws(() => native_window_policy(r.subarray(0, i))); + assert.throws(() => native_window_policy(new Uint8Array([...r, 0]))); + for (const at of [1, 2, 3, 20, 24 + 52]) { const bad = r.slice(); bad[at] = 255; assert.throws(() => native_window_policy(bad)); } + const bad = r.slice(); new DataView(bad.buffer).setUint32(4, 4294967295, true); assert.throws(() => native_window_policy(bad)); +}); diff --git a/src/primitives/canvas/frame.zig b/src/primitives/canvas/frame.zig index 714c9ed18..345e882eb 100644 --- a/src/primitives/canvas/frame.zig +++ b/src/primitives/canvas/frame.zig @@ -1,3 +1,4 @@ +const render_cache = @import("render_cache_policy.zig"); const std = @import("std"); const geometry = @import("geometry"); const canvas = @import("root.zig"); @@ -454,6 +455,7 @@ pub const CanvasFrame = struct { pub const CanvasFrameOptions = struct { text_cache_policy: ?*const fn ([]const u8, []u8) usize = null, + render_cache_policy: ?*const fn ([]const u8, []u8) usize = null, frame_index: u64 = 0, timestamp_ns: u64 = 0, surface_size: geometry.SizeF = .{}, @@ -516,11 +518,14 @@ pub fn buildCanvasFrame(previous: ?DisplayList, next: DisplayList, options: Canv var render_plan = try next.renderPlan(storage.render_commands); const render_override_dirty_bounds = renderOverrideDirtyBounds(render_plan.commands, options.previous_render_overrides, options.render_overrides); render_plan.bounds = applyRenderOverrides(storage.render_commands[0..render_plan.commandCount()], options.render_overrides); + var cache_workspace = render_cache.Workspace.forFrame(options.render_cache_policy, storage, options); + defer if (cache_workspace) |*workspace| workspace.deinit(); const batch_plan = try render_plan.batchPlan(storage.render_batches); const pipeline_cache_plan = if (storage.pipeline_cache_entries.len == 0 and storage.pipeline_cache_actions.len == 0) RenderPipelineCachePlan{} else - try batch_plan.cachePlan( + try batch_plan.cachePlanWithWorkspace( + if (cache_workspace) |*workspace| workspace else null, options.previous_pipeline_cache, options.frame_index, storage.pipeline_cache_entries, @@ -533,7 +538,8 @@ pub fn buildCanvasFrame(previous: ?DisplayList, next: DisplayList, options: Canv const path_geometry_cache_plan = if (storage.path_geometry_cache_entries.len == 0 and storage.path_geometry_cache_actions.len == 0) RenderPathGeometryCachePlan{} else - try path_geometry_plan.cachePlan( + try path_geometry_plan.cachePlanWithWorkspace( + if (cache_workspace) |*workspace| workspace else null, options.previous_path_geometry_cache, options.frame_index, storage.path_geometry_cache_entries, @@ -546,7 +552,8 @@ pub fn buildCanvasFrame(previous: ?DisplayList, next: DisplayList, options: Canv const image_cache_plan = if (storage.image_cache_entries.len == 0 and storage.image_cache_actions.len == 0) RenderImageCachePlan{} else - try image_plan.cachePlan( + try image_plan.cachePlanWithWorkspace( + if (cache_workspace) |*workspace| workspace else null, options.previous_image_cache, options.frame_index, storage.image_cache_entries, @@ -559,14 +566,16 @@ pub fn buildCanvasFrame(previous: ?DisplayList, next: DisplayList, options: Canv const layer_cache_plan = if (storage.layer_cache_entries.len == 0 and storage.layer_cache_actions.len == 0) RenderLayerCachePlan{} else - try layer_plan.cachePlan( + try layer_plan.cachePlanWithWorkspace( + if (cache_workspace) |*workspace| workspace else null, options.previous_layer_cache, options.frame_index, storage.layer_cache_entries, storage.layer_cache_actions, ); const resource_plan = try next.resourcePlan(storage.resources); - const resource_cache_plan = try resource_plan.cachePlan( + const resource_cache_plan = try resource_plan.cachePlanWithWorkspace( + if (cache_workspace) |*workspace| workspace else null, options.previous_resource_cache, options.frame_index, storage.resource_cache_entries, @@ -579,7 +588,8 @@ pub fn buildCanvasFrame(previous: ?DisplayList, next: DisplayList, options: Canv const visual_effect_cache_plan = if (storage.visual_effect_cache_entries.len == 0 and storage.visual_effect_cache_actions.len == 0) VisualEffectCachePlan{} else - try visual_effect_plan.cachePlan( + try visual_effect_plan.cachePlanWithWorkspace( + if (cache_workspace) |*workspace| workspace else null, options.previous_visual_effect_cache, options.frame_index, storage.visual_effect_cache_entries, diff --git a/src/primitives/canvas/render.zig b/src/primitives/canvas/render.zig index af94386b7..5522ddb54 100644 --- a/src/primitives/canvas/render.zig +++ b/src/primitives/canvas/render.zig @@ -1,3 +1,4 @@ +const compiled_cache = @import("render_cache_policy.zig"); const std = @import("std"); const geometry = @import("geometry"); const canvas = @import("root.zig"); @@ -535,6 +536,11 @@ pub const RenderBatchPlan = struct { var planner = RenderPipelineCachePlanner.init(entries, actions); return planner.build(self, previous, frame_index); } + pub fn cachePlanWithWorkspace(self: RenderBatchPlan, workspace: ?*compiled_cache.Workspace, previous: []const RenderPipelineCacheEntry, frame_index: u64, entries: []RenderPipelineCacheEntry, actions: []RenderPipelineCacheAction) Error!RenderPipelineCachePlan { + var planner = RenderPipelineCachePlanner.init(entries, actions); + if (workspace) |owner| return planner.buildCompiled(self, previous, frame_index, owner); + return planner.build(self, previous, frame_index); + } }; pub const RenderBatchPlanner = struct { @@ -648,6 +654,10 @@ pub const RenderPipelineCachePlanner = struct { self.action_len = 0; } + pub fn buildCompiled(self: *RenderPipelineCachePlanner, plan: RenderBatchPlan, previous: []const RenderPipelineCacheEntry, frame_index: u64, workspace: *compiled_cache.Workspace) Error!RenderPipelineCachePlan { + return compiled_cache.build(.pipeline, RenderPipelineCachePlan, self, plan.batches, previous, frame_index, workspace, renderPipelineCacheKey, null, error.RenderPipelineCacheListFull); + } + pub fn build(self: *RenderPipelineCachePlanner, batch_plan: RenderBatchPlan, previous: []const RenderPipelineCacheEntry, frame_index: u64) Error!RenderPipelineCachePlan { self.reset(); for (batch_plan.batches, 0..) |batch, batch_index| { @@ -745,3 +755,7 @@ fn unionOptionalBounds(a: ?geometry.RectF, b: ?geometry.RectF) ?geometry.RectF { fn affinesEqual(a: Affine, b: Affine) bool { return equality_model.affinesEqual(a, b); } + +fn renderPipelineCacheKey(batch: RenderBatch) RenderPipelineKind { + return batch.pipeline; +} diff --git a/src/primitives/canvas/render_cache_policy.zig b/src/primitives/canvas/render_cache_policy.zig new file mode 100644 index 000000000..c001e05e3 --- /dev/null +++ b/src/primitives/canvas/render_cache_policy.zig @@ -0,0 +1,136 @@ +const std = @import("std"); + +pub const Policy = *const fn ([]const u8, []u8) usize; +pub const Family = enum(u8) { pipeline, path, image, layer, resource, effect }; + +/// One native-owned pair of copied buffers, reused by the six ordered cache +/// calls in a frame. Resources and committed core storage never cross the ABI. +pub const Workspace = struct { + policy: Policy, + request: []u8, + result: []u8, + + pub fn init(policy: Policy, max_facts: usize, max_entries: usize, max_actions: usize) Workspace { + return .{ + .policy = policy, + .request = std.heap.page_allocator.alloc(u8, 24 + max_facts * 56) catch @panic("render cache request allocation failed"), + .result = std.heap.page_allocator.alloc(u8, 16 + max_entries * 4 + max_actions * 12) catch @panic("render cache result allocation failed"), + }; + } + + pub fn forFrame(policy: ?Policy, storage: anytype, options: anytype) ?Workspace { + const owner = policy orelse return null; + var facts: usize = 0; + var entries: usize = 0; + var actions: usize = 0; + inline for (.{ + .{ "render_batches", "pipeline" }, .{ "path_geometries", "path_geometry" }, + .{ "images", "image" }, .{ "layers", "layer" }, + .{ "resources", "resource" }, .{ "visual_effects", "visual_effect" }, + }) |fields| { + const current = @field(storage, fields[0]).len; + const total = current + @field(options, "previous_" ++ fields[1] ++ "_cache").len; + facts = @max(facts, total); + entries = @max(entries, @min(current, @field(storage, fields[1] ++ "_cache_entries").len)); + actions = @max(actions, @min(total, @field(storage, fields[1] ++ "_cache_actions").len)); + } + return init(owner, facts, entries, actions); + } + + pub fn deinit(self: Workspace) void { + std.heap.page_allocator.free(self.request); + std.heap.page_allocator.free(self.result); + } +}; + +/// The native key and hash functions supply representation/resource facts. +/// The compiled owner decides matching, ordering and every capacity transition. +pub fn build(comptime family: Family, comptime Result: type, planner: anytype, current: anytype, previous: anytype, frame: u64, workspace: *Workspace, comptime keyFn: anytype, comptime hashFn: anytype, comptime full: anytype) @TypeOf(full)!Result { + planner.reset(); + const total = current.len + previous.len; + const request_length = 24 + total * 56; + const result_capacity = 16 + @min(planner.entries.len, current.len) * 4 + @min(planner.actions.len, total) * 12; + if (request_length > workspace.request.len or result_capacity > workspace.result.len) @panic("render cache workspace too small"); + const request = workspace.request[0..request_length]; + @memset(request, 0); + request[0] = 12; + request[1] = @intFromEnum(family); + putWord(request, 4, current.len); + putWord(request, 8, previous.len); + putWord(request, 12, planner.entries.len); + putWord(request, 16, planner.actions.len); + for (current, 0..) |item, i| { + const key = keyFn(item); + fact(request[24 + i * 56 ..][0..56], key, if (comptime family == .resource) hashFn(key) else 0); + } + for (previous, 0..) |item, i| { + const key = if (comptime family == .pipeline) item.pipeline else item.key; + fact(request[24 + (current.len + i) * 56 ..][0..56], key, if (comptime family == .resource) hashFn(key) else 0); + } + const result = workspace.result[0..result_capacity]; + const length = workspace.policy(request, result); + if (length < 16 or length > result.len) @panic("truncated compiled render cache result"); + const entry_count = word(result, 0); + const action_count = word(result, 4); + const failed = word(result, 8); + if (failed > 1 or word(result, 12) != 0 or entry_count > @min(planner.entries.len, current.len) or action_count > @min(planner.actions.len, total) or length != 16 + @as(usize, entry_count) * 4 + @as(usize, action_count) * 12) @panic("invalid compiled render cache result"); + for (0..entry_count) |i| { + const source = word(result, 16 + i * 4); + if (source >= current.len) @panic("invalid compiled render cache entry source"); + const key = keyFn(current[source]); + planner.entries[i] = if (comptime family == .pipeline) .{ .pipeline = key, .last_used_frame = frame } else .{ .key = key, .last_used_frame = frame }; + } + planner.entry_len = entry_count; + for (0..action_count) |i| { + const at = 16 + @as(usize, entry_count) * 4 + i * 12; + const kind = word(result, at); + const source = word(result, at + 4); + const cache = word(result, at + 8); + if (kind > 2 or source >= total or (cache != 0xffffffff and cache >= previous.len) or (kind == 2) != (source >= current.len) or (kind == 0) != (cache == 0xffffffff)) @panic("invalid compiled render cache action"); + const key = if (source < current.len) keyFn(current[source]) else if (comptime family == .pipeline) previous[source - current.len].pipeline else previous[source - current.len].key; + var action: @TypeOf(planner.actions[0]) = undefined; + action.kind = @enumFromInt(kind); + if (comptime family == .pipeline) action.pipeline = key else action.key = key; + @field(action, switch (family) { + .pipeline => "batch_index", + .path => "geometry_index", + .image => "image_index", + .layer => "layer_index", + .resource => "resource_index", + .effect => "effect_index", + }) = if (source < current.len) source else null; + action.cache_index = if (cache == 0xffffffff) null else cache; + planner.actions[i] = action; + } + planner.action_len = action_count; + if (failed == 1) return full; + return .{ .entries = planner.entries[0..entry_count], .actions = planner.actions[0..action_count] }; +} + +fn fact(bytes: []u8, key: anytype, hash: u64) void { + const T = @TypeOf(key); + if (comptime @typeInfo(T) == .@"enum") { + putWord(bytes, 0, @intFromEnum(key)); + } else { + if (comptime @hasField(T, "kind")) putWord(bytes, 0, @intFromEnum(key.kind)); + if (comptime @hasField(T, "id")) { + putWord(bytes, 4, @intFromBool(key.id != null)); + putInt(bytes, 8, key.id orelse 0); + } + if (comptime @hasField(T, "command_index")) putInt(bytes, 16, key.command_index); + if (comptime @hasField(T, "command_start")) putInt(bytes, 16, key.command_start); + if (comptime @hasField(T, "image_id")) putInt(bytes, 24, key.image_id); + if (comptime @hasField(T, "font_id")) putInt(bytes, 32, key.font_id); + putInt(bytes, 40, key.fingerprint); + } + putWord(bytes, 48, @intCast(hash & 0xffffffff)); +} +fn putWord(bytes: []u8, at: usize, value: usize) void { + std.mem.writeInt(u32, bytes[at..][0..4], std.math.cast(u32, value) orelse @panic("render cache wire integer overflow"), .little); +} +fn putInt(bytes: []u8, at: usize, value: u64) void { + std.mem.writeInt(u64, bytes[at..][0..8], value, .little); +} +fn word(bytes: []const u8, at: usize) u32 { + return std.mem.readInt(u32, bytes[at..][0..4], .little); +} diff --git a/src/primitives/canvas/render_effects.zig b/src/primitives/canvas/render_effects.zig index 26ecd2af8..38def2c90 100644 --- a/src/primitives/canvas/render_effects.zig +++ b/src/primitives/canvas/render_effects.zig @@ -1,3 +1,4 @@ +const compiled_cache = @import("render_cache_policy.zig"); const geometry = @import("geometry"); const canvas = @import("root.zig"); const drawing_model = @import("drawing.zig"); @@ -57,6 +58,11 @@ pub const VisualEffectPlan = struct { } return count; } + pub fn cachePlanWithWorkspace(self: VisualEffectPlan, workspace: ?*compiled_cache.Workspace, previous: []const VisualEffectCacheEntry, frame_index: u64, entries: []VisualEffectCacheEntry, actions: []VisualEffectCacheAction) Error!VisualEffectCachePlan { + var planner = VisualEffectCachePlanner.init(entries, actions); + if (workspace) |owner| return planner.buildCompiled(self, previous, frame_index, owner); + return planner.build(self, previous, frame_index); + } }; pub const VisualEffectPlanner = struct { @@ -184,6 +190,10 @@ pub const VisualEffectCachePlanner = struct { self.action_len = 0; } + pub fn buildCompiled(self: *VisualEffectCachePlanner, plan: VisualEffectPlan, previous: []const VisualEffectCacheEntry, frame_index: u64, workspace: *compiled_cache.Workspace) Error!VisualEffectCachePlan { + return compiled_cache.build(.effect, VisualEffectCachePlan, self, plan.effects, previous, frame_index, workspace, visualEffectKey, null, error.VisualEffectCacheListFull); + } + pub fn build(self: *VisualEffectCachePlanner, effect_plan: VisualEffectPlan, previous: []const VisualEffectCacheEntry, frame_index: u64) Error!VisualEffectCachePlan { self.reset(); for (effect_plan.effects, 0..) |effect, effect_index| { diff --git a/src/primitives/canvas/render_generic_resources.zig b/src/primitives/canvas/render_generic_resources.zig index ff74ca73e..0d2296553 100644 --- a/src/primitives/canvas/render_generic_resources.zig +++ b/src/primitives/canvas/render_generic_resources.zig @@ -1,3 +1,4 @@ +const compiled_cache = @import("render_cache_policy.zig"); const geometry = @import("geometry"); const canvas = @import("root.zig"); const drawing_model = @import("drawing.zig"); @@ -54,6 +55,11 @@ pub const RenderResourcePlan = struct { var planner = RenderResourceCachePlanner.init(entries, actions); return planner.build(self, previous, frame_index); } + pub fn cachePlanWithWorkspace(self: RenderResourcePlan, workspace: ?*compiled_cache.Workspace, previous: []const RenderResourceCacheEntry, frame_index: u64, entries: []RenderResourceCacheEntry, actions: []RenderResourceCacheAction) Error!RenderResourceCachePlan { + var planner = RenderResourceCachePlanner.init(entries, actions); + if (workspace) |owner| return planner.buildCompiled(self, previous, frame_index, owner); + return planner.build(self, previous, frame_index); + } }; pub const RenderResourcePlanner = struct { @@ -220,6 +226,10 @@ pub const RenderResourceCachePlanner = struct { self.action_len = 0; } + pub fn buildCompiled(self: *RenderResourceCachePlanner, plan: RenderResourcePlan, previous: []const RenderResourceCacheEntry, frame_index: u64, workspace: *compiled_cache.Workspace) Error!RenderResourceCachePlan { + return compiled_cache.build(.resource, RenderResourceCachePlan, self, plan.resources, previous, frame_index, workspace, renderResourceKey, renderResourceKeyHash, error.RenderResourceCacheListFull); + } + pub fn build(self: *RenderResourceCachePlanner, resource_plan: RenderResourcePlan, previous: []const RenderResourceCacheEntry, frame_index: u64) Error!RenderResourceCachePlan { self.reset(); // Keyed lookups ride the probe-table index whenever the inputs diff --git a/src/primitives/canvas/render_images.zig b/src/primitives/canvas/render_images.zig index d6e15843a..4599d3df6 100644 --- a/src/primitives/canvas/render_images.zig +++ b/src/primitives/canvas/render_images.zig @@ -1,3 +1,4 @@ +const compiled_cache = @import("render_cache_policy.zig"); const geometry = @import("geometry"); const canvas = @import("root.zig"); const drawing_model = @import("drawing.zig"); @@ -38,6 +39,11 @@ pub const RenderImagePlan = struct { var planner = RenderImageCachePlanner.init(entries, actions); return planner.build(self, previous, frame_index); } + pub fn cachePlanWithWorkspace(self: RenderImagePlan, workspace: ?*compiled_cache.Workspace, previous: []const RenderImageCacheEntry, frame_index: u64, entries: []RenderImageCacheEntry, actions: []RenderImageCacheAction) Error!RenderImageCachePlan { + var planner = RenderImageCachePlanner.init(entries, actions); + if (workspace) |owner| return planner.buildCompiled(self, previous, frame_index, owner); + return planner.build(self, previous, frame_index); + } }; pub const RenderImageKey = struct { @@ -166,6 +172,10 @@ pub const RenderImageCachePlanner = struct { self.action_len = 0; } + pub fn buildCompiled(self: *RenderImageCachePlanner, plan: RenderImagePlan, previous: []const RenderImageCacheEntry, frame_index: u64, workspace: *compiled_cache.Workspace) Error!RenderImageCachePlan { + return compiled_cache.build(.image, RenderImageCachePlan, self, plan.images, previous, frame_index, workspace, renderImageKey, null, error.ImageCacheListFull); + } + pub fn build(self: *RenderImageCachePlanner, image_plan: RenderImagePlan, previous: []const RenderImageCacheEntry, frame_index: u64) Error!RenderImageCachePlan { self.reset(); for (image_plan.images, 0..) |image, image_index| { diff --git a/src/primitives/canvas/render_layers.zig b/src/primitives/canvas/render_layers.zig index f6c254ed1..6654add5e 100644 --- a/src/primitives/canvas/render_layers.zig +++ b/src/primitives/canvas/render_layers.zig @@ -1,3 +1,4 @@ +const compiled_cache = @import("render_cache_policy.zig"); const geometry = @import("geometry"); const canvas = @import("root.zig"); const drawing_model = @import("drawing.zig"); @@ -59,6 +60,11 @@ pub const RenderLayerPlan = struct { var planner = RenderLayerCachePlanner.init(entries, actions); return planner.build(self, previous, frame_index); } + pub fn cachePlanWithWorkspace(self: RenderLayerPlan, workspace: ?*compiled_cache.Workspace, previous: []const RenderLayerCacheEntry, frame_index: u64, entries: []RenderLayerCacheEntry, actions: []RenderLayerCacheAction) Error!RenderLayerCachePlan { + var planner = RenderLayerCachePlanner.init(entries, actions); + if (workspace) |owner| return planner.buildCompiled(self, previous, frame_index, owner); + return planner.build(self, previous, frame_index); + } }; pub const RenderLayerPlanner = struct { @@ -180,6 +186,10 @@ pub const RenderLayerCachePlanner = struct { self.action_len = 0; } + pub fn buildCompiled(self: *RenderLayerCachePlanner, plan: RenderLayerPlan, previous: []const RenderLayerCacheEntry, frame_index: u64, workspace: *compiled_cache.Workspace) Error!RenderLayerCachePlan { + return compiled_cache.build(.layer, RenderLayerCachePlan, self, plan.layers, previous, frame_index, workspace, renderLayerKey, null, error.LayerCacheListFull); + } + pub fn build(self: *RenderLayerCachePlanner, layer_plan: RenderLayerPlan, previous: []const RenderLayerCacheEntry, frame_index: u64) Error!RenderLayerCachePlan { self.reset(); for (layer_plan.layers, 0..) |layer, layer_index| { diff --git a/src/primitives/canvas/render_paths.zig b/src/primitives/canvas/render_paths.zig index bdae3988d..11ff15885 100644 --- a/src/primitives/canvas/render_paths.zig +++ b/src/primitives/canvas/render_paths.zig @@ -1,3 +1,4 @@ +const compiled_cache = @import("render_cache_policy.zig"); const geometry = @import("geometry"); const canvas = @import("root.zig"); const drawing_model = @import("drawing.zig"); @@ -61,6 +62,11 @@ pub const RenderPathGeometryPlan = struct { var planner = RenderPathGeometryCachePlanner.init(entries, actions); return planner.build(self, previous, frame_index); } + pub fn cachePlanWithWorkspace(self: RenderPathGeometryPlan, workspace: ?*compiled_cache.Workspace, previous: []const RenderPathGeometryCacheEntry, frame_index: u64, entries: []RenderPathGeometryCacheEntry, actions: []RenderPathGeometryCacheAction) Error!RenderPathGeometryCachePlan { + var planner = RenderPathGeometryCachePlanner.init(entries, actions); + if (workspace) |owner| return planner.buildCompiled(self, previous, frame_index, owner); + return planner.build(self, previous, frame_index); + } }; pub const RenderPathGeometryPlanner = struct { @@ -289,6 +295,10 @@ pub const RenderPathGeometryCachePlanner = struct { self.action_len = 0; } + pub fn buildCompiled(self: *RenderPathGeometryCachePlanner, plan: RenderPathGeometryPlan, previous: []const RenderPathGeometryCacheEntry, frame_index: u64, workspace: *compiled_cache.Workspace) Error!RenderPathGeometryCachePlan { + return compiled_cache.build(.path, RenderPathGeometryCachePlan, self, plan.geometries, previous, frame_index, workspace, renderPathGeometryKey, null, error.PathGeometryCacheListFull); + } + pub fn build(self: *RenderPathGeometryCachePlanner, geometry_plan: RenderPathGeometryPlan, previous: []const RenderPathGeometryCacheEntry, frame_index: u64) Error!RenderPathGeometryCachePlan { self.reset(); for (geometry_plan.geometries, 0..) |geometry_plan_item, geometry_index| { diff --git a/src/primitives/canvas/root.zig b/src/primitives/canvas/root.zig index 1778bfffd..9ab290d89 100644 --- a/src/primitives/canvas/root.zig +++ b/src/primitives/canvas/root.zig @@ -1,4 +1,5 @@ const std = @import("std"); +pub const RenderCacheWorkspace = @import("render_cache_policy.zig").Workspace; const command_model = @import("commands.zig"); const drawing_model = @import("drawing.zig"); const text_model = @import("text.zig"); diff --git a/src/runtime/api.zig b/src/runtime/api.zig index 2267be05c..de77dc7fc 100644 --- a/src/runtime/api.zig +++ b/src/runtime/api.zig @@ -429,6 +429,7 @@ pub fn App(comptime Runtime: type) type { /// storage before any OS operation; the owning core cycle resets. shell_layout_policy: ?*const fn ([]const u8, []u8) usize = null, text_cache_policy: ?*const fn ([]const u8, []u8) usize = null, + render_cache_policy: ?*const fn ([]const u8, []u8) usize = null, start_fn: ?StartFn = null, event_fn: ?EventFn = null, stop_fn: ?StopFn = null, diff --git a/src/runtime/canvas_frame.zig b/src/runtime/canvas_frame.zig index 49b638419..800ec121f 100644 --- a/src/runtime/canvas_frame.zig +++ b/src/runtime/canvas_frame.zig @@ -220,6 +220,7 @@ pub fn RuntimeCanvasFrames(comptime Runtime: type) type { var frame_options = options; if (frame_options.text_cache_policy == null) frame_options.text_cache_policy = self.text_cache_policy; + if (frame_options.render_cache_policy == null) frame_options.render_cache_policy = self.render_cache_policy; if (frame_options.surface_size.isEmpty()) frame_options.surface_size = self.views[index].frame.size(); frame_options.backdrop_blur_sample_extent_multiplier = backdropBlurSampleExtentMultiplier(self.options.platform.name); return self.views[index].canvasDisplayList().framePlan(previous, frame_options, storage); @@ -877,6 +878,7 @@ pub fn RuntimeCanvasFrames(comptime Runtime: type) type { defer launch_timing.lapOnce("first_plan_done"); var frame_options = options; if (frame_options.text_cache_policy == null) frame_options.text_cache_policy = self.text_cache_policy; + if (frame_options.render_cache_policy == null) frame_options.render_cache_policy = self.render_cache_policy; frame_options.backdrop_blur_sample_extent_multiplier = backdropBlurSampleExtentMultiplier(self.options.platform.name); if (frame_options.surface_size.isEmpty()) { frame_options.surface_size = if (self.views[index].gpu_size.isEmpty()) self.views[index].frame.size() else self.views[index].gpu_size; @@ -949,11 +951,14 @@ pub fn RuntimeCanvasFrames(comptime Runtime: type) type { const render_override_dirty_bounds = canvas.renderOverrideDirtyBounds(render_plan.commands, frame_options.previous_render_overrides, frame_options.render_overrides); const render_animation_dirty_bounds = self.views[index].canvasRenderAnimationDirtyBoundsForOverrides(frame_options.previous_render_overrides, frame_options.render_overrides); render_plan.bounds = canvas.applyRenderOverrides(storage.render_commands[0..render_plan.commandCount()], frame_options.render_overrides); + var cache_workspace = canvas.RenderCacheWorkspace.forFrame(frame_options.render_cache_policy, storage, frame_options); + defer if (cache_workspace) |*workspace| workspace.deinit(); const batch_plan = try render_plan.batchPlan(storage.render_batches); const pipeline_cache_plan = if (storage.pipeline_cache_entries.len == 0 and storage.pipeline_cache_actions.len == 0) canvas.RenderPipelineCachePlan{} else - try batch_plan.cachePlan( + try batch_plan.cachePlanWithWorkspace( + if (cache_workspace) |*workspace| workspace else null, frame_options.previous_pipeline_cache, frame_options.frame_index, storage.pipeline_cache_entries, @@ -966,7 +971,8 @@ pub fn RuntimeCanvasFrames(comptime Runtime: type) type { const path_geometry_cache_plan = if (storage.path_geometry_cache_entries.len == 0 and storage.path_geometry_cache_actions.len == 0) canvas.RenderPathGeometryCachePlan{} else - try path_geometry_plan.cachePlan( + try path_geometry_plan.cachePlanWithWorkspace( + if (cache_workspace) |*workspace| workspace else null, frame_options.previous_path_geometry_cache, frame_options.frame_index, storage.path_geometry_cache_entries, @@ -979,7 +985,8 @@ pub fn RuntimeCanvasFrames(comptime Runtime: type) type { const image_cache_plan = if (storage.image_cache_entries.len == 0 and storage.image_cache_actions.len == 0) canvas.RenderImageCachePlan{} else - try image_plan.cachePlan( + try image_plan.cachePlanWithWorkspace( + if (cache_workspace) |*workspace| workspace else null, frame_options.previous_image_cache, frame_options.frame_index, storage.image_cache_entries, @@ -992,14 +999,16 @@ pub fn RuntimeCanvasFrames(comptime Runtime: type) type { const layer_cache_plan = if (storage.layer_cache_entries.len == 0 and storage.layer_cache_actions.len == 0) canvas.RenderLayerCachePlan{} else - try layer_plan.cachePlan( + try layer_plan.cachePlanWithWorkspace( + if (cache_workspace) |*workspace| workspace else null, frame_options.previous_layer_cache, frame_options.frame_index, storage.layer_cache_entries, storage.layer_cache_actions, ); const resource_plan = try display_list.resourcePlan(storage.resources); - const resource_cache_plan = try resource_plan.cachePlan( + const resource_cache_plan = try resource_plan.cachePlanWithWorkspace( + if (cache_workspace) |*workspace| workspace else null, frame_options.previous_resource_cache, frame_options.frame_index, storage.resource_cache_entries, @@ -1012,7 +1021,8 @@ pub fn RuntimeCanvasFrames(comptime Runtime: type) type { const visual_effect_cache_plan = if (storage.visual_effect_cache_entries.len == 0 and storage.visual_effect_cache_actions.len == 0) canvas.VisualEffectCachePlan{} else - try visual_effect_plan.cachePlan( + try visual_effect_plan.cachePlanWithWorkspace( + if (cache_workspace) |*workspace| workspace else null, frame_options.previous_visual_effect_cache, frame_options.frame_index, storage.visual_effect_cache_entries, diff --git a/src/runtime/core.zig b/src/runtime/core.zig index 1ab521fbe..4aa50f7d0 100644 --- a/src/runtime/core.zig +++ b/src/runtime/core.zig @@ -260,6 +260,7 @@ pub const Runtime = struct { shell_layout_count: usize = 0, shell_layout_policy: ?*const fn ([]const u8, []u8) usize = null, text_cache_policy: ?*const fn ([]const u8, []u8) usize = null, + render_cache_policy: ?*const fn ([]const u8, []u8) usize = null, /// Reused by runtime-created window restoration. `Store.loadWindow` /// needs the whole bounded state file plus decoded-string scratch; keep /// that 64-KiB workspace on the already heap-owned Runtime instead of diff --git a/src/runtime/flow.zig b/src/runtime/flow.zig index 8398f71ba..f2f50d2ca 100644 --- a/src/runtime/flow.zig +++ b/src/runtime/flow.zig @@ -229,6 +229,7 @@ pub fn RuntimeFlow(comptime Runtime: type) type { .app_start => { self.shell_layout_policy = app.shell_layout_policy; self.text_cache_policy = app.text_cache_policy; + self.render_cache_policy = app.render_cache_policy; // A startup failure must be loud and attributable. // Without this record the error unwinds into the // platform stop path and the only trace is a bare diff --git a/src/runtime/ts_ui_app.zig b/src/runtime/ts_ui_app.zig index ef0bf549d..7a6558177 100644 --- a/src/runtime/ts_ui_app.zig +++ b/src/runtime/ts_ui_app.zig @@ -426,6 +426,8 @@ pub fn TsUiAppWithFeatures(comptime core: type, comptime features: ui_app.UiAppF stamped.shell_layout_policy = core.nativeWindowPolicy; if (options.text_cache_policy != null) @panic("TsUiApp owns text_cache_policy - remove custom text cache wiring"); stamped.text_cache_policy = core.nativeWindowPolicy; + if (options.render_cache_policy != null) @panic("TsUiApp owns render_cache_policy - remove custom render cache wiring"); + stamped.render_cache_policy = core.nativeWindowPolicy; } if (comptime @hasDecl(core, "nativeTabFocusPolicy")) { if (options.tab_focus_policy != null) @panic("TsUiApp owns tab_focus_policy - remove custom focus policy wiring"); diff --git a/src/runtime/ui_app.zig b/src/runtime/ui_app.zig index 2435c0388..45763a4f9 100644 --- a/src/runtime/ui_app.zig +++ b/src/runtime/ui_app.zig @@ -485,6 +485,7 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe scene: app_manifest.ShellConfig, shell_layout_policy: ?*const fn ([]const u8, []u8) usize = null, text_cache_policy: ?*const fn ([]const u8, []u8) usize = null, + render_cache_policy: ?*const fn ([]const u8, []u8) usize = null, canvas_label: []const u8, /// Fixed design tokens for an app that owns its look. Leave /// null (the default) and the stock tokens FOLLOW THE SYSTEM @@ -1508,6 +1509,7 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe .scene_fn = sceneFn, .shell_layout_policy = self.options.shell_layout_policy, .text_cache_policy = self.options.text_cache_policy, + .render_cache_policy = self.options.render_cache_policy, .event_fn = eventFn, .stop_fn = stopFn, .replay_fn = replayFn, diff --git a/tests/ts-core/render_cache_e2e_tests.zig b/tests/ts-core/render_cache_e2e_tests.zig new file mode 100644 index 000000000..ca6161c03 --- /dev/null +++ b/tests/ts-core/render_cache_e2e_tests.zig @@ -0,0 +1,244 @@ +const std = @import("std"); +const sdk = @import("native_sdk"); +const core = @import("surface_fixture_core"); +const canvas = sdk.canvas; +const exact = @import("surface_layout_e2e_tests.zig").expectComplete; +const Workspace = canvas.RenderCacheWorkspace; + +fn check(comptime Planner: type, plan: anytype, previous: anytype, capacity: usize, action_capacity: usize, frame: u64) !void { + const Entry = @FieldType(Planner, "entries"); + const Action = @FieldType(Planner, "actions"); + const allocator = std.testing.allocator; + const a = try allocator.alloc(@typeInfo(Entry).pointer.child, capacity); + defer allocator.free(a); + const b = try allocator.alloc(@typeInfo(Entry).pointer.child, capacity); + defer allocator.free(b); + const aa = try allocator.alloc(@typeInfo(Action).pointer.child, action_capacity); + defer allocator.free(aa); + const ba = try allocator.alloc(@typeInfo(Action).pointer.child, action_capacity); + defer allocator.free(ba); + const P = @TypeOf(plan); + const count = @field(plan, if (@hasField(P, "batches")) "batches" else if (@hasField(P, "geometries")) "geometries" else if (@hasField(P, "images")) "images" else if (@hasField(P, "layers")) "layers" else if (@hasField(P, "resources")) "resources" else "effects").len; + var workspace = Workspace.init(core.nativeWindowPolicy, count + previous.len, @min(capacity, count), @min(action_capacity, count + previous.len)); + defer workspace.deinit(); + var native = Planner.init(a, aa); + var compiled = Planner.init(b, ba); + const expected = native.build(plan, previous, frame); + const actual = compiled.buildCompiled(plan, previous, frame, &workspace); + if (expected) |result| try exact(result, try actual) else |err| try std.testing.expectError(err, actual); + try exact(native.entry_len, compiled.entry_len); + try exact(native.action_len, compiled.action_len); + try exact(a[0..native.entry_len], b[0..compiled.entry_len]); + try exact(aa[0..native.action_len], ba[0..compiled.action_len]); +} + +test "compiled pipeline cache preserves full records first previous matches and action-before-entry failures" { + _ = core.initialModel(); + defer core.rt.frameReset(); + const plan = canvas.RenderBatchPlan{ .batches = &.{ .{ .pipeline = .path, .command_count = 9 }, .{ .pipeline = .path }, .{ .pipeline = .image } } }; + const old = [_]canvas.RenderPipelineCacheEntry{ .{ .pipeline = .path, .last_used_frame = 9007199254740993 }, .{ .pipeline = .path, .last_used_frame = 7 }, .{ .pipeline = .blur }, .{ .pipeline = .blur } }; + for ([_]usize{ 0, 1, 2, 9 }) |capacity| for ([_]usize{ 0, 1, 2, 3, 9 }) |actions| { + try check(canvas.RenderPipelineCachePlanner, plan, &old, capacity, actions, 0xffffffffffffffff); + core.rt.frameReset(); + }; +} + +test "compiled path image layer and effect caches preserve exact keys positions frame stamps and partial writes" { + _ = core.initialModel(); + defer core.rt.frameReset(); + const id: u64 = 9007199254740993; + const paths = canvas.RenderPathGeometryPlan{ .geometries = &.{ .{ .kind = .fill, .id = 0, .command_index = 99, .fingerprint = id }, .{ .kind = .fill, .id = 0, .command_index = 100, .fingerprint = id }, .{ .kind = .stroke, .command_index = id, .fingerprint = id + 1 } } }; + const old_paths = [_]canvas.RenderPathGeometryCacheEntry{ .{ .key = .{ .kind = .fill, .id = 0, .fingerprint = id } }, .{ .key = .{ .kind = .fill, .id = 0, .fingerprint = id } }, .{ .key = .{ .kind = .stroke, .id = id, .fingerprint = 0xffffffffffffffff } }, .{ .key = .{ .kind = .stroke, .id = id, .fingerprint = 0xffffffffffffffff } } }; + const images = canvas.RenderImagePlan{ .images = &.{ .{ .image_id = id, .fingerprint = id }, .{ .image_id = id, .fingerprint = id }, .{ .image_id = id + 1, .fingerprint = id } } }; + const old_images = [_]canvas.RenderImageCacheEntry{ .{ .key = .{ .image_id = id, .fingerprint = id } }, .{ .key = .{ .image_id = id, .fingerprint = id } }, .{ .key = .{ .image_id = id, .fingerprint = id + 1 } } }; + const layers = canvas.RenderLayerPlan{ .layers = &.{ .{ .id = 0, .command_start = id, .fingerprint = id }, .{ .id = 0, .command_start = id + 1, .fingerprint = id }, .{ .command_start = id, .fingerprint = id } } }; + const old_layers = [_]canvas.RenderLayerCacheEntry{ .{ .key = .{ .id = 0, .fingerprint = id } }, .{ .key = .{ .id = 0, .fingerprint = id } }, .{ .key = .{ .command_start = id + 1, .fingerprint = id } }, .{ .key = .{ .id = 0, .command_start = 1, .fingerprint = id } } }; + const effects = canvas.VisualEffectPlan{ .effects = &.{ .{ .kind = .blur, .id = 0, .command_index = id, .fingerprint = id }, .{ .kind = .blur, .id = 0, .command_index = id + 1, .fingerprint = id }, .{ .kind = .shadow, .command_index = id, .fingerprint = id } } }; + const old_effects = [_]canvas.VisualEffectCacheEntry{ .{ .key = .{ .kind = .blur, .id = 0, .fingerprint = id } }, .{ .key = .{ .kind = .blur, .id = 0, .fingerprint = id } }, .{ .key = .{ .kind = .shadow, .command_index = id + 1, .fingerprint = id } }, .{ .key = .{ .kind = .shadow, .command_index = id + 1, .fingerprint = id } } }; + for ([_]usize{ 0, 1, 2, 9 }) |capacity| for ([_]usize{ 0, 1, 2, 3, 9 }) |actions| for ([_]u64{ 0, id, 0xffffffffffffffff }) |frame| { + try check(canvas.RenderPathGeometryCachePlanner, paths, &old_paths, capacity, actions, frame); + try check(canvas.RenderImageCachePlanner, images, &old_images, capacity, actions, frame); + try check(canvas.RenderLayerCachePlanner, layers, &old_layers, capacity, actions, frame); + try check(canvas.VisualEffectCachePlanner, effects, &old_effects, capacity, actions, frame); + core.rt.frameReset(); + }; +} + +test "compiled resource cache preserves every key field indexed thresholds oversized inputs and ordered failure state" { + _ = core.initialModel(); + defer core.rt.frameReset(); + const id: u64 = 9007199254740993; + const small = canvas.RenderResourcePlan{ .resources = &.{ .{ .kind = .image, .command_index = id, .image_id = id, .font_id = id + 1, .fingerprint = id + 2 }, .{ .kind = .image, .command_index = id + 1, .image_id = id, .font_id = id + 1, .fingerprint = id + 2 }, .{ .kind = .glyph_run, .id = 0, .command_index = id, .image_id = id, .font_id = id + 2, .fingerprint = id + 2 } } }; + const old = [_]canvas.RenderResourceCacheEntry{ .{ .key = .{ .kind = .image, .image_id = id, .font_id = id + 1, .fingerprint = id + 2 } }, .{ .key = .{ .kind = .image, .image_id = id, .font_id = id + 1, .fingerprint = id + 2 } }, .{ .key = .{ .kind = .glyph_run, .id = 0, .command_index = 1, .image_id = id, .font_id = id + 2, .fingerprint = id + 2 } } }; + for ([_]usize{ 0, 1, 2, 9 }) |capacity| for ([_]usize{ 0, 1, 2, 3, 9 }) |actions| { + try check(canvas.RenderResourceCachePlanner, small, &old, capacity, actions, 0xffffffffffffffff); + core.rt.frameReset(); + }; + const allocator = std.testing.allocator; + for ([_]usize{ 63, 64, 2048, 2049 }) |count| { + const resources = try allocator.alloc(canvas.RenderResource, count); + defer allocator.free(resources); + const previous = try allocator.alloc(canvas.RenderResourceCacheEntry, count); + defer allocator.free(previous); + for (resources, previous, 0..) |*r, *p, i| { + r.* = .{ .kind = .image, .command_index = i, .image_id = id + i / 2, .fingerprint = id + 1 }; + p.* = .{ .key = .{ .kind = .image, .image_id = r.image_id, .fingerprint = r.fingerprint }, .last_used_frame = 8 }; + } + try check(canvas.RenderResourceCachePlanner, canvas.RenderResourcePlan{ .resources = resources }, previous, count, count * 2, id); + try check(canvas.RenderResourceCachePlanner, canvas.RenderResourcePlan{}, previous, 0, count, id); + core.rt.frameReset(); + } +} + +test "six compiled cache calls reuse native buffers and preserve borrowed core bytes" { + _ = core.initialModel(); + defer core.rt.frameReset(); + const borrowed = core.modelSnapshot(); + const before = try std.testing.allocator.dupe(u8, borrowed); + defer std.testing.allocator.free(before); + var workspace = Workspace.init(core.nativeWindowPolicy, 4, 4, 4); + defer workspace.deinit(); + const request_pointer = workspace.request.ptr; + const result_pointer = workspace.result.ptr; + inline for (.{ canvas.RenderPipelineCachePlanner, canvas.RenderPathGeometryCachePlanner, canvas.RenderImageCachePlanner, canvas.RenderLayerCachePlanner, canvas.RenderResourceCachePlanner, canvas.VisualEffectCachePlanner }, .{ canvas.RenderBatchPlan, canvas.RenderPathGeometryPlan, canvas.RenderImagePlan, canvas.RenderLayerPlan, canvas.RenderResourcePlan, canvas.VisualEffectPlan }) |Planner, Plan| { + var entries: [1]@typeInfo(@FieldType(Planner, "entries")).pointer.child = undefined; + var actions: [1]@typeInfo(@FieldType(Planner, "actions")).pointer.child = undefined; + var planner = Planner.init(&entries, &actions); + _ = try planner.buildCompiled(Plan{}, &.{}, 9007199254740993, &workspace); + try std.testing.expect(workspace.request.ptr == request_pointer and workspace.result.ptr == result_pointer); + try std.testing.expectEqualSlices(u8, before, borrowed); + } +} + +const Storage = struct { + value: canvas.CanvasFrameStorage, + fn init() !Storage { + var result: Storage = undefined; + inline for (@typeInfo(canvas.CanvasFrameStorage).@"struct".fields) |field| { + const E = @typeInfo(field.type).pointer.child; + const buffer = try std.testing.allocator.alloc(E, 64); + @memset(std.mem.sliceAsBytes(buffer), 0); + @field(result.value, field.name) = buffer; + } + return result; + } + fn deinit(self: Storage) void { + inline for (@typeInfo(canvas.CanvasFrameStorage).@"struct".fields) |field| std.testing.allocator.free(@field(self.value, field.name)); + } +}; +const path_elements = [_]canvas.PathElement{ .{ .verb = .move_to, .points = .{ .init(2, 2), .{}, .{} } }, .{ .verb = .line_to, .points = .{ .init(20, 20), .{}, .{} } }, .{ .verb = .line_to, .points = .{ .init(2, 20), .{}, .{} } }, .{ .verb = .close } }; +const scene = [_]canvas.CanvasCommand{ + .{ .push_opacity = 0.5 }, + .{ .fill_path = .{ .id = 1, .elements = &path_elements, .fill = .{ .color = canvas.Color.rgb8(20, 40, 60) } } }, + .{ .draw_image = .{ .id = 2, .image_id = 9, .dst = .init(20, 20, 30, 30) } }, + .{ .shadow = .{ .id = 3, .rect = .init(20, 20, 30, 30), .blur = 3, .color = canvas.Color.rgb8(0, 0, 0) } }, + .{ .blur = .{ .id = 4, .rect = .init(40, 40, 30, 30), .radius = 2 } }, + .pop_opacity, +}; +var family_calls: [6]usize = .{0} ** 6; +fn observedPolicy(request: []const u8, output: []u8) usize { + if (request[0] == 12) family_calls[request[1]] += 1; + return core.nativeWindowPolicy(request, output); +} + +test "both runtime frame consumers use all six compiled caches and preserve complete warm changed and empty frames" { + _ = core.initialModel(); + defer core.rt.frameReset(); + const native = try sdk.runtime.TestHarness().create(std.testing.allocator, .{}); + defer native.destroy(std.testing.allocator); + const compiled = try sdk.runtime.TestHarness().create(std.testing.allocator, .{}); + defer compiled.destroy(std.testing.allocator); + var context: u8 = 0; + for ([_]@TypeOf(native){ native, compiled }, 0..) |h, lane| { + h.null_platform.gpu_surfaces = true; + try h.start(.{ .context = &context, .name = "cache-parity", .source = sdk.platform.WebViewSource.html(""), .render_cache_policy = if (lane == 1) observedPolicy else null }); + _ = try h.runtime.createView(.{ .window_id = 1, .label = "canvas", .kind = .gpu_surface, .frame = .init(0, 0, 160, 120) }); + } + try std.testing.expect(compiled.runtime.render_cache_policy == observedPolicy); + var a = try Storage.init(); + defer a.deinit(); + var b = try Storage.init(); + defer b.deinit(); + var changed = scene; + changed[2].draw_image.image_id = 10; + changed[3].shadow.blur = 4; + for ([_][]const canvas.CanvasCommand{ &scene, &scene, &changed, &.{} }, 0..) |commands, phase| { + for ([_]@TypeOf(native){ native, compiled }) |h| _ = try h.runtime.setCanvasDisplayList(1, "canvas", .{ .commands = commands }); + const options = canvas.CanvasFrameOptions{ .frame_index = 9007199254740993 + phase, .timestamp_ns = 33, .full_repaint = true }; + family_calls = .{0} ** 6; + const diagnostic = try native.runtime.canvasFramePlan(1, "canvas", null, options, a.value); + try frameEqual(diagnostic, try compiled.runtime.canvasFramePlan(1, "canvas", null, options, b.value)); + try exact([_]usize{1} ** 6, family_calls); + family_calls = .{0} ** 6; + const presentation = try native.runtime.nextCanvasFrame(1, "canvas", options, a.value); + try frameEqual(presentation, try compiled.runtime.nextCanvasFrame(1, "canvas", options, b.value)); + try exact([_]usize{1} ** 6, family_calls); + if (phase < 3) { + try std.testing.expect(presentation.pipeline_cache_plan.entries.len > 0); + try std.testing.expect(presentation.path_geometry_cache_plan.entries.len > 0); + try std.testing.expect(presentation.image_cache_plan.entries.len > 0); + try std.testing.expect(presentation.layer_cache_plan.entries.len > 0); + try std.testing.expect(presentation.resource_cache_plan.entries.len > 0); + try std.testing.expect(presentation.visual_effect_cache_plan.entries.len > 0); + } + core.rt.frameReset(); + } +} + +test "frame cache failure preserves action-before-entry storage and prevents downstream extraction" { + _ = core.initialModel(); + defer core.rt.frameReset(); + var a = try Storage.init(); + defer a.deinit(); + var b = try Storage.init(); + defer b.deinit(); + const list = canvas.DisplayList{ .commands = &scene }; + var sa = a.value; + var sb = b.value; + sa.pipeline_cache_entries = sa.pipeline_cache_entries[0..0]; + sb.pipeline_cache_entries = sb.pipeline_cache_entries[0..0]; + sa.pipeline_cache_actions = sa.pipeline_cache_actions[0..1]; + sb.pipeline_cache_actions = sb.pipeline_cache_actions[0..1]; + // Later resource extraction is also too small. Pipeline failure must win. + sa.resources = sa.resources[0..0]; + sb.resources = sb.resources[0..0]; + try std.testing.expectError(error.RenderPipelineCacheListFull, list.framePlan(null, .{}, sa)); + family_calls = .{0} ** 6; + try std.testing.expectError(error.RenderPipelineCacheListFull, list.framePlan(null, .{ .render_cache_policy = observedPolicy }, sb)); + try exact([_]usize{ 1, 0, 0, 0, 0, 0 }, family_calls); + try exact(a.value, b.value); + try std.testing.expectEqual(@as(@TypeOf(b.value.pipeline_cache_actions[0].kind), .upload), b.value.pipeline_cache_actions[0].kind); + try std.testing.expectEqual(@as(?usize, 0), b.value.pipeline_cache_actions[0].batch_index); +} + +test "six cache planning cost is measured with frame buffer allocation copying and reset" { + _ = core.initialModel(); + defer core.rt.frameReset(); + var storage = try Storage.init(); + defer storage.deinit(); + const list = canvas.DisplayList{ .commands = &scene }; + const warm = try list.framePlan(null, .{}, storage.value); + // Keep warm native records separate from the next frame's mutable storage. + var output = try Storage.init(); + defer output.deinit(); + const options = canvas.CanvasFrameOptions{ .previous_pipeline_cache = warm.pipeline_cache_plan.entries, .previous_path_geometry_cache = warm.path_geometry_cache_plan.entries, .previous_image_cache = warm.image_cache_plan.entries, .previous_layer_cache = warm.layer_cache_plan.entries, .previous_resource_cache = warm.resource_cache_plan.entries, .previous_visual_effect_cache = warm.visual_effect_cache_plan.entries }; + for ([_]?*const fn ([]const u8, []u8) usize{ null, core.nativeWindowPolicy }, 0..) |policy, lane| { + var selected = options; + selected.render_cache_policy = policy; + const begin = std.Io.Timestamp.now(std.testing.io, .awake).nanoseconds; + for (0..100) |_| { + const frame = try list.framePlan(null, selected, output.value); + std.mem.doNotOptimizeAway(frame); + core.rt.frameReset(); + } + std.debug.print("six-cache complete frame lane {d}: {d} ns (including frame buffers, copying and enclosing reset)\n", .{ lane, @divTrunc(std.Io.Timestamp.now(std.testing.io, .awake).nanoseconds - begin, 100) }); + } +} + +fn frameEqual(a: canvas.CanvasFrame, b: canvas.CanvasFrame) !void { + inline for (@typeInfo(canvas.CanvasFrame).@"struct".fields) |field| { + if (comptime !std.mem.eql(u8, field.name, "dirty_rects")) try exact(@field(a, field.name), @field(b, field.name)); + } + // The fixed backing array is undefined past the published count. + try exact(a.dirtyRects(), b.dirtyRects()); +} diff --git a/tests/ts-core/surface_layout_e2e_tests.zig b/tests/ts-core/surface_layout_e2e_tests.zig index ea51199aa..b70135367 100644 --- a/tests/ts-core/surface_layout_e2e_tests.zig +++ b/tests/ts-core/surface_layout_e2e_tests.zig @@ -47,6 +47,7 @@ test { _ = @import("semantic_tree_e2e_tests.zig"); _ = @import("virtual_extent_e2e_tests.zig"); _ = @import("text_cache_e2e_tests.zig"); + _ = @import("render_cache_e2e_tests.zig"); } test "compiled modal defaults and f32 boundaries match the native reference" { @@ -218,6 +219,7 @@ test "TypeScript app adapter supplies placement through every theme path" { const app = try Adapter.create(std.testing.allocator, .{}, .{ .name = "surface-layout", .scene = .{}, .canvas_label = "canvas", .view = Factory.view, .window_view = Factory.windowView, .tokens = if (mode == 1) .{} else null, .tokens_fn = if (mode == 2) Factory.tokens else null }); defer app.destroy(); try std.testing.expect(app.app().text_cache_policy == core.nativeWindowPolicy); + try std.testing.expect(app.app().render_cache_policy == core.nativeWindowPolicy); const tokens = app.effectiveTokens().withOverrides(.{}); try std.testing.expect(tokens.surface_layout_policy == core.nativeWindowPolicy); try std.testing.expect(tokens.grid_layout_policy == core.nativeWindowPolicy); @@ -248,6 +250,7 @@ test "app token paths retain adapter placement and consume supplied frames" { const app = try App.create(std.testing.allocator, .{ .name = "surface-layout", .scene = .{}, .canvas_label = "canvas", .surface_layout_policy = Factory.policy, .intrinsic_layout_policy = core.nativeWindowPolicy, .view = Factory.view, .update = Factory.update, .tokens = if (mode == 1) .{} else null, .tokens_fn = if (mode == 2) Factory.tokens else null }); defer app.destroy(); try std.testing.expect(app.app().text_cache_policy == null); + try std.testing.expect(app.app().render_cache_policy == null); const tokens = app.effectiveTokens().withOverrides(.{}); try std.testing.expect(tokens.surface_layout_policy == Factory.policy); try std.testing.expect(tokens.intrinsic_layout_policy == core.nativeWindowPolicy);