Skip to content

Commit 254da31

Browse files
authored
Native color matrix (#840)
1 parent f04840e commit 254da31

File tree

14 files changed

+3484
-735
lines changed

14 files changed

+3484
-735
lines changed
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
#!/usr/bin/env bun
2+
3+
import { performance } from "node:perf_hooks"
4+
import { OptimizedBuffer } from "../buffer"
5+
import { VignetteEffect } from "../post/effects"
6+
7+
type Scenario = { width: number; height: number }
8+
type ScenarioResult = {
9+
size: string
10+
cells: number
11+
avgMs: number
12+
avgNsPerCell: number
13+
medianMs: number
14+
p95Ms: number
15+
}
16+
17+
const ITERATIONS = 5000
18+
const WARMUP_ITERATIONS = 100
19+
const STRENGTH = 0.7
20+
const baseScenarios: Array<{ width: number; height: number }> = [
21+
{ width: 40, height: 20 },
22+
{ width: 80, height: 24 },
23+
{ width: 120, height: 40 },
24+
{ width: 200, height: 60 },
25+
]
26+
const scenarios: Scenario[] = baseScenarios
27+
28+
function calculateStats(samples: number[]): { avgMs: number; medianMs: number; p95Ms: number } {
29+
const sorted = [...samples].sort((a, b) => a - b)
30+
const total = samples.reduce((sum, value) => sum + value, 0)
31+
const avgMs = total / samples.length
32+
const mid = Math.floor(sorted.length / 2)
33+
const medianMs = sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]
34+
const p95Index = Math.floor(0.95 * (sorted.length - 1))
35+
const p95Ms = sorted[p95Index]
36+
37+
return { avgMs, medianMs, p95Ms }
38+
}
39+
40+
function formatMs(value: number): number {
41+
return Number(value.toFixed(4))
42+
}
43+
44+
function formatNs(value: number): number {
45+
return Number(value.toFixed(2))
46+
}
47+
48+
function runScenario({ width, height }: Scenario): ScenarioResult {
49+
const buffer = OptimizedBuffer.create(width, height, "unicode", { id: `vignette-bench-${width}x${height}` })
50+
const vignetteEffect = new VignetteEffect(STRENGTH)
51+
const { fg, bg } = buffer.buffers
52+
fg.fill(1)
53+
bg.fill(1)
54+
55+
for (let i = 0; i < WARMUP_ITERATIONS; i++) {
56+
vignetteEffect.apply(buffer)
57+
}
58+
59+
const samples = new Array<number>(ITERATIONS)
60+
for (let i = 0; i < ITERATIONS; i++) {
61+
const start = performance.now()
62+
vignetteEffect.apply(buffer)
63+
samples[i] = performance.now() - start
64+
}
65+
66+
buffer.destroy()
67+
68+
const stats = calculateStats(samples)
69+
return {
70+
size: `${width}x${height}`,
71+
cells: width * height,
72+
avgMs: formatMs(stats.avgMs),
73+
avgNsPerCell: formatNs((stats.avgMs * 1_000_000) / (width * height)),
74+
medianMs: formatMs(stats.medianMs),
75+
p95Ms: formatMs(stats.p95Ms),
76+
}
77+
}
78+
79+
console.log(`Vignette Effect Benchmark (${ITERATIONS} iterations per scenario)`)
80+
const results = scenarios.map(runScenario)
81+
console.table(results)
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
#!/usr/bin/env bun
2+
3+
import { performance } from "node:perf_hooks"
4+
import { OptimizedBuffer } from "../buffer"
5+
6+
type Scenario = { width: number; height: number; mode: "uniform" | "mask25" | "mask100" }
7+
type ScenarioResult = {
8+
size: string
9+
cells: number
10+
mode: "uniform" | "mask25" | "mask100"
11+
avgMs: number
12+
avgNsPerCell: number
13+
medianMs: number
14+
p95Ms: number
15+
}
16+
17+
const sepiaMatrix = new Float32Array([
18+
0.393, 0.769, 0.189, 0, 0.349, 0.686, 0.168, 0, 0.272, 0.534, 0.131, 0, 0, 0, 0, 1,
19+
])
20+
21+
const ITERATIONS = 1000
22+
const WARMUP_ITERATIONS = 100
23+
const baseScenarios: Array<{ width: number; height: number }> = [
24+
{ width: 80, height: 24 },
25+
{ width: 120, height: 40 },
26+
{ width: 200, height: 60 },
27+
]
28+
const scenarios: Scenario[] = baseScenarios.flatMap((scenario) => [
29+
{ ...scenario, mode: "uniform" },
30+
{ ...scenario, mode: "mask25" },
31+
{ ...scenario, mode: "mask100" },
32+
])
33+
34+
function generateCellMask(width: number, height: number, density: number): Float32Array {
35+
const totalCells = width * height
36+
const numCells = Math.floor(totalCells * density)
37+
const mask = new Float32Array(numCells * 3)
38+
39+
for (let i = 0; i < numCells; i++) {
40+
mask[i * 3] = i % width
41+
mask[i * 3 + 1] = Math.floor(i / width)
42+
mask[i * 3 + 2] = 1
43+
}
44+
45+
return mask
46+
}
47+
48+
function calculateStats(samples: number[]): { avgMs: number; medianMs: number; p95Ms: number } {
49+
const sorted = [...samples].sort((a, b) => a - b)
50+
const total = samples.reduce((sum, value) => sum + value, 0)
51+
const avgMs = total / samples.length
52+
const mid = Math.floor(sorted.length / 2)
53+
const medianMs = sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]
54+
const p95Index = Math.floor(0.95 * (sorted.length - 1))
55+
const p95Ms = sorted[p95Index]
56+
57+
return { avgMs, medianMs, p95Ms }
58+
}
59+
60+
function formatMs(value: number): number {
61+
return Number(value.toFixed(4))
62+
}
63+
64+
function formatNs(value: number): number {
65+
return Number(value.toFixed(2))
66+
}
67+
68+
function fillBufferColors(buffer: OptimizedBuffer): void {
69+
const { fg, bg } = buffer.buffers
70+
71+
for (let i = 0; i < fg.length; i += 4) {
72+
fg[i] = Math.random()
73+
fg[i + 1] = Math.random()
74+
fg[i + 2] = Math.random()
75+
fg[i + 3] = 1
76+
bg[i] = Math.random()
77+
bg[i + 1] = Math.random()
78+
bg[i + 2] = Math.random()
79+
bg[i + 3] = 1
80+
}
81+
}
82+
83+
function runScenario({ width, height, mode }: Scenario): ScenarioResult {
84+
const buffer = OptimizedBuffer.create(width, height, "unicode", {
85+
id: `colormatrix-bench-${mode}-${width}x${height}`,
86+
})
87+
const cellMask = mode === "mask25" ? generateCellMask(width, height, 0.25) : generateCellMask(width, height, 1)
88+
const cellCount = mode === "uniform" ? width * height : cellMask.length / 3
89+
90+
fillBufferColors(buffer)
91+
92+
for (let i = 0; i < WARMUP_ITERATIONS; i++) {
93+
if (mode === "uniform") {
94+
buffer.colorMatrixUniform(sepiaMatrix, 1.0, 3)
95+
} else {
96+
buffer.colorMatrix(sepiaMatrix, cellMask, 1.0, 3)
97+
}
98+
}
99+
100+
const samples = new Array<number>(ITERATIONS)
101+
for (let i = 0; i < ITERATIONS; i++) {
102+
const start = performance.now()
103+
if (mode === "uniform") {
104+
buffer.colorMatrixUniform(sepiaMatrix, 1.0, 3)
105+
} else {
106+
buffer.colorMatrix(sepiaMatrix, cellMask, 1.0, 3)
107+
}
108+
samples[i] = performance.now() - start
109+
}
110+
111+
buffer.destroy()
112+
113+
const stats = calculateStats(samples)
114+
115+
return {
116+
size: `${width}x${height}`,
117+
cells: cellCount,
118+
mode,
119+
avgMs: formatMs(stats.avgMs),
120+
avgNsPerCell: formatNs((stats.avgMs * 1_000_000) / cellCount),
121+
medianMs: formatMs(stats.medianMs),
122+
p95Ms: formatMs(stats.p95Ms),
123+
}
124+
}
125+
126+
console.log(`ColorMatrix Benchmark (${ITERATIONS} iterations per scenario)`)
127+
const results = scenarios.map(runScenario)
128+
console.table(results)
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
#!/usr/bin/env bun
2+
3+
import { performance } from "node:perf_hooks"
4+
import { OptimizedBuffer } from "../buffer"
5+
import { applyGain } from "../post/filters"
6+
7+
type Scenario = { width: number; height: number }
8+
type ScenarioResult = {
9+
size: string
10+
cells: number
11+
avgMs: number
12+
avgNsPerCell: number
13+
medianMs: number
14+
p95Ms: number
15+
}
16+
17+
const ITERATIONS = 5000
18+
const WARMUP_ITERATIONS = 100
19+
const GAIN_FACTOR = 1.3
20+
const baseScenarios: Array<{ width: number; height: number }> = [
21+
{ width: 40, height: 20 },
22+
{ width: 80, height: 24 },
23+
{ width: 120, height: 40 },
24+
{ width: 200, height: 60 },
25+
]
26+
const scenarios: Scenario[] = baseScenarios
27+
28+
function calculateStats(samples: number[]): { avgMs: number; medianMs: number; p95Ms: number } {
29+
const sorted = [...samples].sort((a, b) => a - b)
30+
const total = samples.reduce((sum, value) => sum + value, 0)
31+
const avgMs = total / samples.length
32+
const mid = Math.floor(sorted.length / 2)
33+
const medianMs = sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]
34+
const p95Index = Math.floor(0.95 * (sorted.length - 1))
35+
const p95Ms = sorted[p95Index]
36+
37+
return { avgMs, medianMs, p95Ms }
38+
}
39+
40+
function formatMs(value: number): number {
41+
return Number(value.toFixed(4))
42+
}
43+
44+
function formatNs(value: number): number {
45+
return Number(value.toFixed(2))
46+
}
47+
48+
function runScenario({ width, height }: Scenario): ScenarioResult {
49+
const buffer = OptimizedBuffer.create(width, height, "unicode", { id: `gain-bench-${width}x${height}` })
50+
const { fg, bg } = buffer.buffers
51+
fg.fill(1)
52+
bg.fill(1)
53+
54+
for (let i = 0; i < WARMUP_ITERATIONS; i++) {
55+
applyGain(buffer, GAIN_FACTOR)
56+
}
57+
58+
const samples = new Array<number>(ITERATIONS)
59+
for (let i = 0; i < ITERATIONS; i++) {
60+
const start = performance.now()
61+
applyGain(buffer, GAIN_FACTOR)
62+
samples[i] = performance.now() - start
63+
}
64+
65+
buffer.destroy()
66+
67+
const stats = calculateStats(samples)
68+
return {
69+
size: `${width}x${height}`,
70+
cells: width * height,
71+
avgMs: formatMs(stats.avgMs),
72+
avgNsPerCell: formatNs((stats.avgMs * 1_000_000) / (width * height)),
73+
medianMs: formatMs(stats.medianMs),
74+
p95Ms: formatMs(stats.p95Ms),
75+
}
76+
}
77+
78+
console.log(`Gain Benchmark (${ITERATIONS} iterations per scenario)`)
79+
const results = scenarios.map(runScenario)
80+
console.table(results)

packages/core/src/buffer.ts

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
1-
import type { TextBuffer } from "./text-buffer.js"
2-
import { RGBA } from "./lib/index.js"
3-
import { resolveRenderLib, type RenderLib } from "./zig.js"
1+
import { RGBA } from "./lib"
2+
import { resolveRenderLib, type RenderLib } from "./zig"
43
import { type Pointer, toArrayBuffer, ptr } from "bun:ffi"
54
import { type BorderStyle, type BorderSides, BorderCharArrays, parseBorderStyle } from "./lib/index.js"
6-
import { type WidthMethod, type CapturedSpan, type CapturedLine } from "./types.js"
5+
import { TargetChannel, type WidthMethod, type CapturedSpan, type CapturedLine } from "./types.js"
76
import type { TextBufferView } from "./text-buffer-view.js"
87
import type { EditorView } from "./editor-view.js"
98

@@ -288,6 +287,30 @@ export class OptimizedBuffer {
288287
this.lib.bufferFillRect(this.bufferPtr, x, y, width, height, bg)
289288
}
290289

290+
public colorMatrix(
291+
matrix: Float32Array,
292+
cellMask: Float32Array,
293+
strength: number = 1.0,
294+
target: TargetChannel = TargetChannel.Both,
295+
): void {
296+
this.guard()
297+
if (matrix.length !== 16) throw new RangeError(`colorMatrix matrix must have length 16, got ${matrix.length}`)
298+
const cellMaskCount = Math.floor(cellMask.length / 3)
299+
this.lib.bufferColorMatrix(this.bufferPtr, ptr(matrix), ptr(cellMask), cellMaskCount, strength, target)
300+
}
301+
302+
public colorMatrixUniform(
303+
matrix: Float32Array,
304+
strength: number = 1.0,
305+
target: TargetChannel = TargetChannel.Both,
306+
): void {
307+
this.guard()
308+
if (matrix.length !== 16)
309+
throw new RangeError(`colorMatrixUniform matrix must have length 16, got ${matrix.length}`)
310+
if (strength === 0.0) return
311+
this.lib.bufferColorMatrixUniform(this.bufferPtr, ptr(matrix), strength, target)
312+
}
313+
291314
public drawFrameBuffer(
292315
destX: number,
293316
destY: number,

0 commit comments

Comments
 (0)