[shaders] Factor out much of the shader handling code into ShaderNode.
- First step towards handling child shaders.
- Also makes the code more testable.
- Includes fixes for uniform-color-sk since this refactor exposed
a bug where colors didn't round-trip.
Bug: skia:11272
Change-Id: I6b7b7a22eca77a49f767c09b475b8706f3ed6112
Reviewed-on: https://skia-review.googlesource.com/c/buildbot/+/371156
Commit-Queue: Joe Gregorio <jcgregorio@google.com>
Reviewed-by: Kevin Lubick <kjlubick@google.com>
diff --git a/infra-sk/modules/uniform-color-sk/uniform-color-sk.ts b/infra-sk/modules/uniform-color-sk/uniform-color-sk.ts
index 348a766..15d2a15 100644
--- a/infra-sk/modules/uniform-color-sk/uniform-color-sk.ts
+++ b/infra-sk/modules/uniform-color-sk/uniform-color-sk.ts
@@ -20,7 +20,7 @@
slot: 0,
};
-// Converts the uniform value in the range [0, 1] into a two digit hex string.
+/** Converts the uniform value in the range [0, 1] into a two digit hex string. */
export const slotToHex = (uniforms: number[], slot: number): string => {
const s = Math.floor(0.5 + uniforms[slot] * 255).toString(16);
if (s.length === 1) {
@@ -29,6 +29,14 @@
return s;
};
+/** Converts the two digit hex into a uniform value in the range [0, 1] */
+export const hexToSlot = (hexDigits: string, uniforms: number[], slot: number): void => {
+ let colorAsFloat = parseInt(hexDigits, 16) / 255;
+ // Truncate to 4 digits of precision.
+ colorAsFloat = Math.floor(colorAsFloat * 10000) / 10000;
+ uniforms[slot] = colorAsFloat;
+};
+
export class UniformColorSk extends ElementSk implements UniformControl {
private _uniform: Uniform = defaultUniform;
@@ -75,12 +83,9 @@
applyUniformValues(uniforms: number[]): void {
// Set all three floats from the color.
const hex = this.colorInput!.value;
- const r = parseInt(hex.slice(1, 3), 16) / 255;
- const g = parseInt(hex.slice(3, 5), 16) / 255;
- const b = parseInt(hex.slice(5, 7), 16) / 255;
- uniforms[this.uniform.slot] = r;
- uniforms[this.uniform.slot + 1] = g;
- uniforms[this.uniform.slot + 2] = b;
+ hexToSlot(hex.slice(1, 3), uniforms, this.uniform.slot);
+ hexToSlot(hex.slice(3, 5), uniforms, this.uniform.slot + 1);
+ hexToSlot(hex.slice(5, 7), uniforms, this.uniform.slot + 2);
// Set the alpha channel if present.
if (this.hasAlphaChannel()) {
diff --git a/infra-sk/modules/uniform-color-sk/uniform-color-sk_test.ts b/infra-sk/modules/uniform-color-sk/uniform-color-sk_test.ts
index 63abbee..a9da2af 100644
--- a/infra-sk/modules/uniform-color-sk/uniform-color-sk_test.ts
+++ b/infra-sk/modules/uniform-color-sk/uniform-color-sk_test.ts
@@ -1,7 +1,7 @@
import './index';
import { assert } from 'chai';
import { $$ } from 'common-sk/modules/dom';
-import { slotToHex, UniformColorSk } from './uniform-color-sk';
+import { hexToSlot, slotToHex, UniformColorSk } from './uniform-color-sk';
import { setUpElementUnderTest } from '../test_util';
describe('uniform-color-sk', () => {
@@ -19,6 +19,29 @@
});
});
+ describe('hexToSlot', () => {
+ it('converts the two digit hex into a float and stores it in the right slot.', () => {
+ const uniforms = [0, 0, 0];
+ hexToSlot('05', uniforms, 1);
+ assert.deepEqual(uniforms, [0, 0.0196, 0]);
+ });
+ });
+
+ describe('hexToSlot and slotToHex', () => {
+ it('roundtrip correctly from number to hex and back to number', () => {
+ const uniforms = [0, 0, 0];
+ for (let i = 0; i < 255; i++) {
+ // Convert a known hex and place it in slot 1.
+ hexToSlot(i.toString(16), uniforms, 1);
+
+ // Now roundtrip that value out and back into slot 2.
+ const hex = slotToHex(uniforms, 1);
+ hexToSlot(hex, uniforms, 2);
+
+ assert.equal(uniforms[1], uniforms[2]);
+ }
+ });
+ });
describe('uniform-color-sk', () => {
it('puts values in correct spot in uniforms array', () => {
@@ -36,7 +59,7 @@
element.applyUniformValues(uniforms);
assert.deepEqual(
uniforms,
- [0, 128 / 255, 144 / 255, 160 / 255, 0],
+ [0, 0.5019, 0.5647, 0.6274, 0],
);
});
@@ -55,7 +78,7 @@
element.applyUniformValues(uniforms);
assert.deepEqual(
uniforms,
- [0, 128 / 255, 144 / 255, 160 / 255, 0.5, 0],
+ [0, 0.5019, 0.5647, 0.6274, 0.5, 0],
);
});
@@ -78,11 +101,10 @@
element.applyUniformValues(uniforms);
assert.deepEqual(
uniforms,
- [0, 128 / 255, 144 / 255, 160 / 255, 0.5, 0],
+ [0, 0.5019, 0.5647, 0.6274, 0.5, 0],
);
});
-
it('throws on invalid uniforms', () => {
// Uniform is too small to be a color.
assert.throws(() => {
diff --git a/particles/modules/json/index.ts b/particles/modules/json/index.ts
index e8c484d..ed2d5cb 100644
--- a/particles/modules/json/index.ts
+++ b/particles/modules/json/index.ts
@@ -3,9 +3,14 @@
export interface SVGMetaData {
}
+export interface ChildShader {
+ UniformName: string;
+ ScrapHashOrName: string;
+}
+
export interface SKSLMetaData {
Uniforms: number[] | null;
- Children: string[] | null;
+ Children: ChildShader[] | null;
}
export interface ParticlesMetaData {
diff --git a/scrap/go/scrap/scrap.go b/scrap/go/scrap/scrap.go
index 1378f20..ff32ab1 100644
--- a/scrap/go/scrap/scrap.go
+++ b/scrap/go/scrap/scrap.go
@@ -125,14 +125,21 @@
Value float64
}
+// ChildShader is the scrap id of a single child shader along with the name that
+// the uniform should have to access it.
+type ChildShader struct {
+ UniformName string
+ ScrapHashOrName string
+}
+
// SKSLMetaData is metadata for SKSL scraps.
type SKSLMetaData struct {
// Uniforms are all the inputs to the shader.
Uniforms []float32
- // Child shaders. These values are the hashes of shaders, or, if the value
- // begins with an "@", they are the name of a named shader.
- Children []string
+ // Child shaders. A slice because order is important when mapping uniform
+ // names in code to child shaders passed to makeShaderWithChildren.
+ Children []ChildShader
}
// ParticlesMetaData is metadata for Particle scraps.
diff --git a/shaders/Makefile b/shaders/Makefile
index fc025ce..9746afd 100644
--- a/shaders/Makefile
+++ b/shaders/Makefile
@@ -55,6 +55,12 @@
# Run the generated tests just once under Xvfb.
xvfb-run --auto-servernum --server-args "-screen 0 1280x1024x24" npx karma start --single-run
+.PHONY: testjs-watch
+testjs-watch:
+ # Run the generated tests just once under Xvfb.
+ xvfb-run --auto-servernum --server-args "-screen 0 1280x1024x24" npx karma start --no-single-run
+
+
get_latest_skia:
docker pull gcr.io/skia-public/skia-wasm-release:prod
diff --git a/shaders/create-named-scraps.sh b/shaders/create-named-scraps.sh
index 8d11c36..337b3d2 100755
--- a/shaders/create-named-scraps.sh
+++ b/shaders/create-named-scraps.sh
@@ -11,7 +11,7 @@
# that is complete it should be the canconical way to create named scraps.
# Create a name for each scrap.
-curl --silent -X PUT -d "{\"Hash\": \"7f3550775ad8dd3889eda2ce02429ca0e5234290a5888f3c936394e717547a0c\", \"Description\": \"Shader Inputs\"}" -H 'Content-Type: application/json' http://localhost:9000/_/names/sksl/@inputs
+curl --silent -X PUT -d "{\"Hash\": \"26a447d730ba7afe4df7709b9079fbe2d592136dca60b8ddab7e1b56ea302791\", \"Description\": \"Shader Inputs\"}" -H 'Content-Type: application/json' http://localhost:9000/_/names/sksl/@inputs
curl --silent -X PUT -d "{\"Hash\": \"f9ae5d2b4d9b4f5f60ae47b46c034bee16290739831f490ad014c3ba93d13e46\", \"Description\": \"Shader Inputs\"}" -H 'Content-Type: application/json' http://localhost:9000/_/names/sksl/@iResolution
curl --silent -X PUT -d "{\"Hash\": \"c56c6550edb52aff98320153ab05a2bcfa1f300e62a5401e37d16814aaabd618\", \"Description\": \"Shader Inputs\"}" -H 'Content-Type: application/json' http://localhost:9000/_/names/sksl/@iTime
curl --silent -X PUT -d "{\"Hash\": \"4bca396ca53e90795bda2920a1002a7733149bfe6543eddfa1b803d187581a61\", \"Description\": \"Shader Inputs\"}" -H 'Content-Type: application/json' http://localhost:9000/_/names/sksl/@iMouse
diff --git a/shaders/modules/json/index.ts b/shaders/modules/json/index.ts
index e8c484d..ed2d5cb 100644
--- a/shaders/modules/json/index.ts
+++ b/shaders/modules/json/index.ts
@@ -3,9 +3,14 @@
export interface SVGMetaData {
}
+export interface ChildShader {
+ UniformName: string;
+ ScrapHashOrName: string;
+}
+
export interface SKSLMetaData {
Uniforms: number[] | null;
- Children: string[] | null;
+ Children: ChildShader[] | null;
}
export interface ParticlesMetaData {
diff --git a/shaders/modules/shadernode/index.ts b/shaders/modules/shadernode/index.ts
new file mode 100644
index 0000000..f34ca05
--- /dev/null
+++ b/shaders/modules/shadernode/index.ts
@@ -0,0 +1,334 @@
+/** Defines functions and interfaces for working with a shader.
+ *
+ * Example:
+ *
+ * const node = new ShaderNode(ck, [imageShader1, imageShader2]);
+ * node.compile();
+ * const shader = node.getShader(predefinedUniformValues);
+ */
+import { jsonOrThrow } from 'common-sk/modules/jsonOrThrow';
+import { Uniform } from '../../../infra-sk/modules/uniform/uniform';
+import {
+ CanvasKit,
+ MallocObj, RuntimeEffect, Shader,
+} from '../../build/canvaskit/canvaskit';
+import { ScrapBody, ScrapID } from '../json';
+
+export const predefinedUniforms = `uniform float3 iResolution; // Viewport resolution (pixels)
+uniform float iTime; // Shader playback time (s)
+uniform float4 iMouse; // Mouse drag pos=.xy Click pos=.zw (pixels)
+uniform float3 iImageResolution; // iImage1 and iImage2 resolution (pixels)
+uniform shader iImage1; // An input image (Mandrill).
+uniform shader iImage2; // An input image (Soccer ball).`;
+
+/** How many of the uniforms listed in predefinedUniforms are of type 'shader'? */
+export const numPredefinedShaderUniforms = predefinedUniforms.match(/^uniform shader/gm)!.length;
+
+/**
+ * Counts the number of uniforms defined in 'predefinedUniforms'. All the
+ * remaining uniforms that start with 'i' will be referred to as "user
+ * uniforms".
+ */
+export const numPredefinedUniforms = predefinedUniforms.match(/^uniform/gm)!.length - numPredefinedShaderUniforms;
+
+/**
+ * The number of lines prefixed to every shader for predefined uniforms. Needed
+ * to properly adjust error line numbers.
+ */
+export const numPredefinedUniformLines = predefinedUniforms.split('\n').length;
+
+/**
+ * Regex that finds lines in shader compiler error messages that mention a line number
+ * and makes that line number available as a capture.
+ */
+export const shaderCompilerErrorRegex = /^error: (\d+)/i;
+
+/** The default shader to fall back to if nothing can be loaded. */
+export const defaultShader = `half4 main(float2 fragCoord) {
+ return vec4(1, 0, 0, 1);
+}`;
+
+const defaultBody: ScrapBody = {
+ Type: 'sksl',
+ Body: defaultShader,
+ SKSLMetaData: {
+ Uniforms: [],
+ Children: [],
+ },
+};
+
+/**
+ * Called ShaderNode because once we support child shaders this will be just one
+ * node in a tree of shaders.
+ */
+export class ShaderNode {
+ /** The scrap ID this shader was last saved as. */
+ private scrapID: string = '';
+
+ /** The saved configuration of the shader. */
+ private body: ScrapBody | null = null;
+
+ /** The shader code compiled. */
+ private effect: RuntimeEffect | null = null;
+
+ private inputImageShaders: Shader[] = [];
+
+ private canvasKit: CanvasKit;
+
+ private uniforms: Uniform[] = [];
+
+ private uniformFloatCount: number = 0;
+
+ /**
+ * Keep a MallocObj around to pass uniforms to the shader to avoid the need
+ * to make copies.
+ */
+ private uniformsMallocObj: MallocObj | null = null;
+
+ /**
+ * Records the code that is currently running, which might differ from the
+ * code in the editor, and the code that was last saved.
+ */
+ private runningCode = defaultShader;
+
+ /**
+ * The current code in the editor, which might differ from the currently
+ * running code, and the code that was last saved.
+ */
+ private _shaderCode = defaultShader;
+
+ private _compileErrorMessage: string = '';
+
+ private _compileErrorLineNumbers: number[] = [];
+
+ /**
+ * These are the uniform values for all the user defined uniforms. They
+ * exclude the predefined uniform values.
+ */
+ private _currentUserUniformValues: number[] = [];
+
+ private _numPredefinedUniformValues: number = 0;
+
+ constructor(canvasKit: CanvasKit, inputImageShaders: Shader[]) {
+ this.canvasKit = canvasKit;
+ if (inputImageShaders.length !== numPredefinedShaderUniforms) {
+ throw new Error(`ShaderNode requires exactly two predefined image shaders, got ${inputImageShaders.length}`);
+ }
+ this.inputImageShaders = inputImageShaders;
+ this.body = defaultBody;
+ }
+
+ /** Loads a scrap from the backend for the given scrap id. */
+ async loadScrap(scrapID: string): Promise<void> {
+ this.scrapID = scrapID;
+ const resp = await fetch(`/_/load/${scrapID}`, {
+ credentials: 'include',
+ });
+ const scrapBody = (await jsonOrThrow(resp)) as ScrapBody;
+ this.setScrap(scrapBody);
+ }
+
+ /** Sets the code and uniforms of a shader to run. */
+ setScrap(scrapBody: ScrapBody): void {
+ this.body = scrapBody;
+ this._shaderCode = this.body.Body;
+ this.currentUserUniformValues = this.body.SKSLMetaData?.Uniforms || [];
+ this.compile();
+ }
+
+ /**
+ * Saves the scrap to the backend returning a Promise that resolves to the
+ * scrap id that it was stored at, or reject on an error.
+ */
+ async saveScrap(): Promise<string> {
+ const body: ScrapBody = {
+ Body: this._shaderCode,
+ Type: 'sksl',
+ SKSLMetaData: {
+ Uniforms: this._currentUserUniformValues,
+ Children: [],
+ },
+ };
+
+ // POST the JSON to /_/upload
+ const resp = await fetch('/_/save/', {
+ credentials: 'include',
+ body: JSON.stringify(body),
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ method: 'POST',
+ });
+ const json = (await jsonOrThrow(resp)) as ScrapID;
+ this.scrapID = json.Hash;
+ this.body = body;
+ return this.scrapID;
+ }
+
+ /**
+ * The possibly edited shader code. If compile has not been called then it
+ * may differ from the current running code as embodied in the getShader()
+ * response.
+ */
+ get shaderCode(): string { return this._shaderCode; }
+
+ set shaderCode(val: string) {
+ this._shaderCode = val;
+ }
+
+ /**
+ * The values that should be used for the used defined uniforms, as opposed
+ * to the predefined uniform values.
+ * */
+ get currentUserUniformValues(): number[] { return this._currentUserUniformValues; }
+
+ set currentUserUniformValues(val: number[]) {
+ this._currentUserUniformValues = val;
+ }
+
+ /**
+ * The full shader compile error message. Only updated on a call to
+ * compile().
+ */
+ get compileErrorMessage(): string {
+ return this._compileErrorMessage;
+ }
+
+ /** The line numbers that errors occurred on. Only updated on a call to
+ * compile(). */
+ get compileErrorLineNumbers(): number[] {
+ return this._compileErrorLineNumbers;
+ }
+
+ /** Compiles the shader code for this node. */
+ compile(): void {
+ this._compileErrorMessage = '';
+ this._compileErrorLineNumbers = [];
+ this.runningCode = this._shaderCode;
+ // eslint-disable-next-line no-unused-expressions
+ this.effect?.delete();
+ this.effect = this.canvasKit!.RuntimeEffect.Make(`${predefinedUniforms}\n${this.runningCode}`, (err) => {
+ // Fix up the line numbers on the error messages, because they are off by
+ // the number of lines we prefixed with the predefined uniforms. The regex
+ // captures the line number so we can replace it with the correct value.
+ // While doing the fix up of the error message we also annotate the
+ // corresponding lines in the CodeMirror editor.
+ err = err.replace(shaderCompilerErrorRegex, (_match, firstRegexCaptureValue): string => {
+ const lineNumber = (+firstRegexCaptureValue - (numPredefinedUniformLines + 1));
+ this._compileErrorLineNumbers.push(lineNumber);
+ return `error: ${lineNumber.toFixed(0)}`;
+ });
+ this._compileErrorMessage = err;
+ });
+
+ // Do some precalculations to avoid bouncing into WASM code too much.
+ this.uniformFloatCount = this.effect?.getUniformFloatCount() || 0;
+ this.buildUniformsFromEffect();
+ this.calcNumPredefinedUniformValues();
+ this.mallocUniformsMallocObj();
+
+ // Fix up currentUserUniformValues if it's the wrong length.
+ const userUniformValuesLength = this.uniformFloatCount - this._numPredefinedUniformValues;
+ if (this.currentUserUniformValues.length !== userUniformValuesLength) {
+ this.currentUserUniformValues = new Array(userUniformValuesLength).fill(0.5);
+ }
+ }
+
+ /** Returns true if this node needs to have its code recompiled. */
+ needsCompile(): boolean {
+ return (this._shaderCode !== this.runningCode);
+ }
+
+ /** Returns true if this node or any child node needs to be saved. */
+ needsSave(): boolean {
+ return (this._shaderCode !== this.body!.Body) || this.userUniformValuesHaveBeenEdited();
+ }
+
+ /** Returns the number of uniforms in the effect. */
+ getUniformCount(): number {
+ return this.uniforms.length;
+ }
+
+ /** Get a description of the uniform at the given index. */
+ getUniform(index: number): Uniform {
+ return this.uniforms[index];
+ }
+
+ /** The total number of floats across all predefined and user uniforms. */
+ getUniformFloatCount(): number {
+ return this.uniformFloatCount;
+ }
+
+ /**
+ * This is really only called once every raf for the shader that has focus,
+ * i.e. that shader that is being displayed on the web UI.
+ */
+ getShader(predefinedUniformsValues: number[]): Shader | null {
+ if (!this.effect) {
+ return null;
+ }
+ const uniformsFloat32Array: Float32Array = this.uniformsMallocObj!.toTypedArray() as Float32Array;
+
+ // Copy in predefined uniforms values.
+ predefinedUniformsValues.forEach((val, index) => { uniformsFloat32Array[index] = val; });
+
+ // Copy in our local edited uniform values to the right spots.
+ this.currentUserUniformValues.forEach((val, index) => { uniformsFloat32Array[index + this._numPredefinedUniformValues] = val; });
+
+ return this.effect!.makeShaderWithChildren(uniformsFloat32Array, false, this.inputImageShaders);
+ }
+
+ get numPredefinedUniformValues(): number {
+ return this._numPredefinedUniformValues;
+ }
+
+ /** The number of floats that are defined by predefined uniforms. */
+ private calcNumPredefinedUniformValues(): void {
+ this._numPredefinedUniformValues = 0;
+ if (!this.effect) {
+ return;
+ }
+ for (let i = 0; i < numPredefinedUniforms; i++) {
+ const u = this.uniforms[i];
+ this._numPredefinedUniformValues += u.rows * u.columns;
+ }
+ }
+
+ /**
+ * Builds this._uniforms from this.effect, which is used to avoid later
+ * repeated calls into WASM.
+ */
+ private buildUniformsFromEffect() {
+ this.uniforms = [];
+ if (!this.effect) {
+ return;
+ }
+ const count = this.effect.getUniformCount();
+ for (let i = 0; i < count; i++) {
+ // Use object spread operator to clone the SkSLUniform and add a name to make a Uniform.
+ this.uniforms.push({ ...this.effect.getUniform(i), name: this.effect.getUniformName(i) });
+ }
+ }
+
+ private mallocUniformsMallocObj(): void {
+ // Copy uniforms into this.uniformsMallocObj, which is kept around to avoid
+ // copying overhead in WASM.
+ if (this.uniformsMallocObj) {
+ this.canvasKit!.Free(this.uniformsMallocObj);
+ }
+ this.uniformsMallocObj = this.canvasKit!.Malloc(Float32Array, this.uniformFloatCount);
+ }
+
+ private userUniformValuesHaveBeenEdited(): boolean {
+ const savedLocalUniformValues = this.body?.SKSLMetaData?.Uniforms || [];
+ if (this._currentUserUniformValues.length !== savedLocalUniformValues.length) {
+ return true;
+ }
+ for (let i = 0; i < this._currentUserUniformValues.length; i++) {
+ if (this._currentUserUniformValues[i] !== savedLocalUniformValues[i]) {
+ return true;
+ }
+ }
+ return false;
+ }
+}
diff --git a/shaders/modules/shadernode/index_test.ts b/shaders/modules/shadernode/index_test.ts
new file mode 100644
index 0000000..e911f4f
--- /dev/null
+++ b/shaders/modules/shadernode/index_test.ts
@@ -0,0 +1,157 @@
+/* eslint-disable dot-notation */
+import './index';
+import { assert } from 'chai';
+import { numPredefinedUniforms, ShaderNode } from './index';
+import { CanvasKit } from '../../build/canvaskit/canvaskit';
+
+// eslint-disable-next-line @typescript-eslint/no-var-requires
+const CanvasKitInit = require('../../build/canvaskit/canvaskit.js');
+
+let canvasKit: CanvasKit | null = null;
+
+const getCanvasKit = async (): Promise<CanvasKit> => {
+ if (canvasKit) {
+ return canvasKit;
+ }
+ canvasKit = await CanvasKitInit({ locateFile: (file: string) => `https://particles.skia.org/dist/${file}` });
+ return canvasKit!;
+};
+
+const createShaderNode = async (): Promise<ShaderNode> => {
+ const ck = await getCanvasKit();
+ const image = ck.MakeImageFromCanvasImageSource(new Image(512, 512));
+ const shader1 = image.makeShaderOptions(ck.TileMode.Clamp, ck.TileMode.Clamp, ck.FilterMode.Linear, ck.MipmapMode.None);
+ const shader2 = image.makeShaderOptions(ck.TileMode.Clamp, ck.TileMode.Clamp, ck.FilterMode.Linear, ck.MipmapMode.None);
+
+ return new ShaderNode(ck, [shader1, shader2]);
+};
+
+describe('ShaderNode', async () => {
+ it('constructor throws when not passed in the correct number of image shaders', async () => {
+ const ck = await getCanvasKit();
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ assert.throws(() => { const node = new ShaderNode(ck, []); });
+ });
+
+ it('constructor builds with a default shader', async () => {
+ const node = await createShaderNode();
+ node.compile();
+
+ // Confirm that all the post-compile pre-calculations are done correctly.
+ assert.equal(node.getUniformCount(), numPredefinedUniforms, 'The default shader doesn\'t have any user uniforms.');
+ assert.equal(node.getUniform(0).name, 'iResolution', 'Confirm the predefined shaders show up in the uniforms.');
+ assert.equal(node.getUniformFloatCount(), node.numPredefinedUniformValues, 'These are equal because the default shader has 0 user uniforms.');
+ assert.isNotNull(node['uniformsMallocObj'], "We Malloc'd");
+ assert.equal(node.numPredefinedUniformValues, 11, 'The number of predefined uniform values is calculated after compile() is called. This value will change if predefinedUniforms is changed.');
+ assert.deepEqual(node.compileErrorLineNumbers, []);
+ assert.equal(node.compileErrorMessage, '');
+ });
+
+ it('updates all values when a new shader is compiled.', async () => {
+ const node = await createShaderNode();
+ node.compile();
+ assert.isNotNull(node.getShader([]));
+
+ // Check our starting values.
+ assert.equal(node.getUniformCount(), numPredefinedUniforms, 'The default shader doesn\'t have any user uniforms.');
+ assert.equal(node.getUniform(0).name, 'iResolution', 'Confirm the predefined shaders show up in the uniforms.');
+ assert.equal(node.getUniformFloatCount(), node.numPredefinedUniformValues, 'These are equal because the default shader has 0 user uniforms.');
+
+ // Set code that has a user uniform, in this case with 4 floats.
+ node.setScrap({
+ Type: 'sksl',
+ Body: `uniform float4 iColorWithAlpha;
+
+ half4 main(float2 fragCoord) {
+ return half4(iColorWithAlpha);
+ }
+ `,
+ SKSLMetaData: {
+ Children: [],
+ Uniforms: [1, 0, 1, 0],
+ },
+ });
+ node.compile();
+ assert.isNotNull(node.getShader([0, 0, 0, 0]));
+
+ // Confirm that all the post-compile pre-calculations are done correctly for the new shader.
+ assert.equal(node.getUniformCount(), numPredefinedUniforms + 1, 'The new shader has 1 user uniform.');
+ assert.equal(node.getUniform(0).name, 'iResolution', 'Confirm the predefined shaders show up in the uniforms.');
+ assert.equal(node.getUniformFloatCount(), node.numPredefinedUniformValues + 4, 'The user uniform contributes 4 floats to the total.');
+ });
+
+ it('correctly indicates when run() needs to be called.', async () => {
+ const node = await createShaderNode();
+ node.compile();
+
+ assert.isFalse(node.needsCompile(), 'Should not need a run immediately after a call to compile().');
+
+ const originalCode = node.shaderCode;
+ node.shaderCode += '\n';
+ assert.isTrue(node.needsCompile(), 'Needs compile when code has changed.');
+ node.shaderCode = originalCode;
+ assert.isFalse(node.needsCompile(), 'No longer needs a compile when change is undone.');
+ });
+
+ it('correctly indicates when save() needs to be called.', async () => {
+ const node = await createShaderNode();
+ node.compile();
+
+ const startingUniformValues = [1, 0, 1, 0];
+ const modifiedUniformValues = [1, 1, 1, 1];
+
+ // Set code that has a user uniform, in this case with 4 floats, because
+ // saving is not only indicated when the code changes, but when the user
+ // uniforms change.
+ node.setScrap({
+ Type: 'sksl',
+ Body: `uniform float4 iColorWithAlpha;
+
+ half4 main(float2 fragCoord) {
+ return half4(iColorWithAlpha);
+ }
+ `,
+ SKSLMetaData: {
+ Children: [],
+ Uniforms: startingUniformValues,
+ },
+ });
+ node.compile();
+
+ // Changing the code means we need to save.
+ const originalCode = node.shaderCode;
+ assert.isFalse(node.needsSave());
+ node.shaderCode += '\n';
+ assert.isTrue(node.needsSave(), 'Needs save if code changed.');
+ node.shaderCode = originalCode;
+ assert.isFalse(node.needsSave(), "Doesn't need save when code restored.");
+
+ // Also changing the user uniform values means we need to save.
+ node.currentUserUniformValues = modifiedUniformValues;
+ assert.isTrue(node.needsSave(), 'Needs save if uniform values changed.');
+ node.currentUserUniformValues = startingUniformValues;
+ assert.isFalse(node.needsSave(), "Doesn't need save if uniform values restored.");
+ });
+
+ it('reports compiler errors', async () => {
+ const node = await createShaderNode();
+ node.compile();
+ node.setScrap({
+ Type: 'sksl',
+ Body: `uniform float4 iColorWithAlpha;
+
+ half4 main(float2 fragCoord) {
+ return half4(iColorWithAlpha) // Missing trailing semicolon.
+ }
+ `,
+ SKSLMetaData: {
+ Children: [],
+ Uniforms: [1, 0, 1, 0],
+ },
+ });
+ node.compile();
+
+ assert.deepEqual(node.compileErrorLineNumbers, [4]);
+ node.compileErrorMessage.startsWith('error: 4:');
+ });
+});
diff --git a/shaders/modules/shaders-app-sk/shaders-app-sk.scss b/shaders/modules/shaders-app-sk/shaders-app-sk.scss
index d4c3c7a..86eb89a 100644
--- a/shaders/modules/shaders-app-sk/shaders-app-sk.scss
+++ b/shaders/modules/shaders-app-sk/shaders-app-sk.scss
@@ -38,6 +38,10 @@
flex-direction: row;
flex-wrap: wrap;
+ #examples {
+ margin: 0 8px 8px 0;
+ }
+
canvas {
margin: 0 16px 16px 0;
}
@@ -111,11 +115,10 @@
details#shaderinputs {
display: initial;
- margin: initial;
padding: initial;
list-style: initial;
box-shadow: initial;
- margin: 0 0 8px 0;
+ margin: 0 8px 8px 0;
font-size: 11px;
textarea {
diff --git a/shaders/modules/shaders-app-sk/shaders-app-sk.ts b/shaders/modules/shaders-app-sk/shaders-app-sk.ts
index dcadbc9..377711e 100644
--- a/shaders/modules/shaders-app-sk/shaders-app-sk.ts
+++ b/shaders/modules/shaders-app-sk/shaders-app-sk.ts
@@ -11,16 +11,13 @@
import CodeMirror from 'codemirror';
import { $$ } from 'common-sk/modules/dom';
import { stateReflector } from 'common-sk/modules/stateReflector';
-import { jsonOrThrow } from 'common-sk/modules/jsonOrThrow';
import { HintableObject } from 'common-sk/modules/hintable';
import { isDarkMode } from '../../../infra-sk/modules/theme-chooser-sk/theme-chooser-sk';
import type {
CanvasKit,
Surface,
Canvas,
- RuntimeEffect,
Paint,
- MallocObj,
Shader,
} from '../../build/canvaskit/canvaskit.js';
@@ -30,7 +27,6 @@
import '../../../infra-sk/modules/theme-chooser-sk';
import { SKIA_VERSION } from '../../build/version';
import { ElementSk } from '../../../infra-sk/modules/ElementSk/ElementSk';
-import { ScrapBody, ScrapID } from '../json';
import '../../../infra-sk/modules/uniform-time-sk';
import '../../../infra-sk/modules/uniform-generic-sk';
import '../../../infra-sk/modules/uniform-dimensions-sk';
@@ -38,9 +34,12 @@
import '../../../infra-sk/modules/uniform-mouse-sk';
import '../../../infra-sk/modules/uniform-color-sk';
import '../../../infra-sk/modules/uniform-imageresolution-sk';
-import { Uniform, UniformControl } from '../../../infra-sk/modules/uniform/uniform';
+import { UniformControl } from '../../../infra-sk/modules/uniform/uniform';
import { FPS } from '../fps/fps';
import { DimensionsChangedEventDetail } from '../../../infra-sk/modules/uniform-dimensions-sk/uniform-dimensions-sk';
+import {
+ defaultShader, numPredefinedUniformLines, predefinedUniforms, ShaderNode,
+} from '../shadernode';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const CanvasKitInit = require('../../build/canvaskit/canvaskit.js');
@@ -48,41 +47,13 @@
// This element might be loaded from a different site, and that means we need
// to be careful about how we construct the URL back to the canvas.wasm file.
// Start by recording the script origin.
-const scriptOrigin = new URL((document!.currentScript as HTMLScriptElement).src)
- .origin;
+const scriptOrigin = new URL((document!.currentScript as HTMLScriptElement).src).origin;
const kitReady = CanvasKitInit({
locateFile: (file: any) => `${scriptOrigin}/dist/${file}`,
});
const DEFAULT_SIZE = 512;
-const predefinedUniforms = `uniform float3 iResolution; // Viewport resolution (pixels)
-uniform float iTime; // Shader playback time (s)
-uniform float4 iMouse; // Mouse drag pos=.xy Click pos=.zw (pixels)
-uniform float3 iImageResolution; // iImage1 and iImage2 resolution (pixels)
-uniform shader iImage1; // An input image (Mandrill).
-uniform shader iImage2; // An input image (Soccer ball).`;
-
-// How many of the uniforms listed in predefinedUniforms are of type 'shader'?
-const numPredefinedShaderUniforms = predefinedUniforms.match(/^uniform shader/gm)!.length;
-
-// Counts the number of uniforms defined in 'predefinedUniforms'. All the
-// remaining uniforms that start with 'i' will be referred to as "user
-// uniforms".
-const numPredefinedUniforms = predefinedUniforms.match(/^uniform/gm)!.length - numPredefinedShaderUniforms;
-
-// The number of lines prefixed to every shader for predefined uniforms. Needed
-// to properly adjust error line numbers.
-const numPredefinedUniformLines = predefinedUniforms.split('\n').length;
-
-const defaultShader = `half4 main(float2 fragCoord) {
- return vec4(1, 0, 0, 1);
-}`;
-
-// Regex that finds lines in shader compiler error messages that mention a line number
-// and makes that line number available as a capture.
-const shaderCompilerErrorRegex = /^error: (\d+)/i;
-
type stateChangedCallback = ()=> void;
// State represents data reflected to/from the URL.
@@ -158,9 +129,9 @@
const RAF_NOT_RUNNING = -1;
export class ShadersAppSk extends ElementSk {
- private width: number = 512;
+ private width: number = DEFAULT_SIZE;
- private height: number = 512;
+ private height: number = DEFAULT_SIZE;
private codeMirror: CodeMirror.Editor | null = null;
@@ -178,42 +149,17 @@
private inputImageShaders: Shader[] = [];
- private effect: RuntimeEffect | null = null;
-
- private state: State = defaultState;
-
- // If not the empty string, this contains the full last shader compiler error
- // message.
- private compileErrorMessage: string = '';
+ private shaderNode: ShaderNode | null = null;
// Records the lines that have been marked as having errors. We keep these
// around so we can clear the error annotations efficiently.
private compileErrorLines: CodeMirror.TextMarker[] = [];
- // Keep a MallocObj around to pass uniforms to the shader to avoid the need to
- // make copies.
- private uniformsMallocObj: MallocObj | null = null;
+ private state: State = defaultState;
// The requestAnimationFrame id if we are running, otherwise we are not running.
private rafID: number = RAF_NOT_RUNNING;
- // Records the code that we started with, either at startup, or after we've saved.
- private lastSavedCode = defaultShader;
-
- // Records the code that is currently running.
- private runningCode = defaultShader;
-
- // The current code in the editor.
- private editedCode = defaultShader;
-
- // These are the uniform values for all the user defined uniforms. They
- // exclude the predefined uniform values.
- private lastSavedUserUniformValues: number[] = [];
-
- // These are the uniform values for all the user defined uniforms. They
- // exclude the predefined uniform values.
- private currentUserUniformValues: number[] = [];
-
// stateReflector update function.
private stateChanged: stateChangedCallback | null = null;
@@ -225,13 +171,12 @@
private static uniformControls = (ele: ShadersAppSk): TemplateResult[] => {
const ret: TemplateResult[] = [];
- const effect = ele.effect;
- if (!effect) {
+ const node = ele.shaderNode;
+ if (!node) {
return ret;
}
- for (let i = 0; i < effect.getUniformCount(); i++) {
- // Use object spread operator to clone the SkSLUniform and add a name to make a Uniform.
- const uniform: Uniform = { ...effect.getUniform(i), name: effect.getUniformName(i) };
+ for (let i = 0; i < node.getUniformCount(); i++) {
+ const uniform = node.getUniform(i);
if (!uniform.name.startsWith('i')) {
continue;
}
@@ -281,7 +226,7 @@
</header>
<main>
<div>
- <p @click=${ele.fastLoad}>Examples: <a href="/?id=@inputs">Uniforms</a> <a href="/?id=@iResolution">iResolution</a> <a href="/?id=@iTime">iTime</a> <a href="/?id=@iMouse">iMouse</a> <a href="/?id=@iImage">iImage</a></p>
+ <p id=examples @click=${ele.fastLoad}>Examples: <a href="/?id=@inputs">Uniforms</a> <a href="/?id=@iResolution">iResolution</a> <a href="/?id=@iTime">iTime</a> <a href="/?id=@iMouse">iMouse</a> <a href="/?id=@iImage">iImage</a></p>
<canvas
id="player"
width=${ele.width}
@@ -306,9 +251,9 @@
</div>
</details>
<div id="codeEditor"></div>
- <div ?hidden=${!ele.compileErrorMessage} id="compileErrors">
+ <div ?hidden=${!ele.shaderNode?.compileErrorMessage} id="compileErrors">
<h3>Errors</h3>
- <pre>${ele.compileErrorMessage}</pre>
+ <pre>${ele.shaderNode?.compileErrorMessage}</pre>
</div>
</div>
<div id=shaderControls>
@@ -319,14 +264,14 @@
${ShadersAppSk.uniformControls(ele)}
</div>
<button
- ?hidden=${ele.editedCode === ele.runningCode}
+ ?hidden=${!ele.shaderNode?.needsCompile()}
@click=${ele.runClick}
class=action
>
Run
</button>
<button
- ?hidden=${ele.editedCode === ele.lastSavedCode && !ele.userUniformValuesHaveBeenEdited()}
+ ?hidden=${!ele.shaderNode?.needsSave()}
@click=${ele.saveClick}
class=action
>
@@ -388,8 +333,9 @@
/* getState */ () => (this.state as unknown) as HintableObject,
/* setState */ (newState: HintableObject) => {
this.state = (newState as unknown) as State;
+ this.shaderNode = new ShaderNode(this.kit!, this.inputImageShaders);
if (!this.state.id) {
- this.startShader(defaultShader);
+ this.run();
} else {
this.loadShaderIfNecessary();
}
@@ -421,14 +367,14 @@
const newDims = (e as CustomEvent<DimensionsChangedEventDetail>).detail;
this.width = newDims.width;
this.height = newDims.height;
- this.startShader(this.runningCode);
+ this.run();
}
private monitorIfDevicePixelRatioChanges() {
// Use matchMedia to detect if the screen resolution changes from the current value.
// See https://developer.mozilla.org/en-US/docs/Web/API/Window/devicePixelRatio#monitoring_screen_resolution_or_zoom_level_changes
const mqString = `(resolution: ${window.devicePixelRatio}dppx)`;
- matchMedia(mqString).addEventListener('change', () => this.startShader(this.runningCode));
+ matchMedia(mqString).addEventListener('change', () => this.run());
}
private async loadShaderIfNecessary() {
@@ -436,17 +382,13 @@
return;
}
try {
- const resp = await fetch(`/_/load/${this.state.id}`, {
- credentials: 'include',
- });
- const json = (await jsonOrThrow(resp)) as ScrapBody;
- this.lastSavedCode = json.Body;
- this.startShader(json.Body);
- if (json.SKSLMetaData && json.SKSLMetaData.Uniforms !== null) {
- this.setCurrentUserUniformValues(json.SKSLMetaData.Uniforms);
- // We round trip the uniforms through the controls so we are sure to get an exact match.
- this.lastSavedUserUniformValues = this.getCurrentUserUniformValues(this.getUniformValuesFromControls());
- }
+ await this.shaderNode!.loadScrap(this.state.id);
+ this._render();
+
+ const predefinedUniformValues = new Array(this.shaderNode!.numPredefinedUniformValues).fill(0);
+ this.setUniformValuesToControls(predefinedUniformValues.concat(this.shaderNode!.currentUserUniformValues));
+
+ this.run();
} catch (error) {
errorMessage(error, 0);
// Return to the default view.
@@ -455,7 +397,7 @@
}
}
- private startShader(shaderCode: string) {
+ private run() {
this.monitorIfDevicePixelRatioChanges();
// Cancel any pending drawFrames.
if (this.rafID !== RAF_NOT_RUNNING) {
@@ -463,9 +405,7 @@
this.rafID = RAF_NOT_RUNNING;
}
- this.runningCode = shaderCode;
- this.editedCode = shaderCode;
- this.codeMirror!.setValue(shaderCode);
+ this.codeMirror!.setValue(this.shaderNode?.shaderCode || defaultShader);
// eslint-disable-next-line no-unused-expressions
this.surface?.delete();
@@ -478,30 +418,17 @@
// the parent surface will do that for us.
this.canvas = this.surface.getCanvas();
this.canvasKitContext = this.kit!.currentContext();
- // eslint-disable-next-line no-unused-expressions
- this.effect?.delete();
this.clearAllEditorErrorAnnotations();
- this.compileErrorMessage = '';
- this.effect = this.kit!.RuntimeEffect.Make(`${predefinedUniforms}\n${shaderCode}`, (err) => {
- // Fix up the line numbers on the error messages, because they are off by
- // the number of lines we prefixed with the predefined uniforms. The regex
- // captures the line number so we can replace it with the correct value.
- // While doing the fix up of the error message we also annotate the
- // corresponding lines in the CodeMirror editor.
- err = err.replace(shaderCompilerErrorRegex, (_match, firstRegexCaptureValue): string => {
- const lineNumber = (+firstRegexCaptureValue - (numPredefinedUniformLines + 1));
- this.setEditorErrorLineAnnotation(lineNumber);
- return `error: ${lineNumber.toFixed(0)}`;
- });
- this.compileErrorMessage = err;
+
+ this.shaderNode!.compile();
+
+ // Set CodeMirror errors if the run failed.
+ this.shaderNode!.compileErrorLineNumbers.forEach((lineNumber: number) => {
+ this.setEditorErrorLineAnnotation(lineNumber);
});
+
// Render so the uniform controls get displayed.
this._render();
-
- if (!this.effect) {
- return;
- }
-
this.drawFrame();
}
@@ -523,90 +450,53 @@
));
}
+ /** Populate the uniforms values from the controls. */
private getUniformValuesFromControls(): number[] {
- // Populate the uniforms values from the controls.
- const uniforms: number[] = new Array(this.effect!.getUniformFloatCount());
+ const uniforms: number[] = new Array(this.shaderNode!.getUniformFloatCount()).fill(0);
$('#uniformControls > *').forEach((control) => {
(control as unknown as UniformControl).applyUniformValues(uniforms);
});
return uniforms;
}
+ /** Populate the control values from the uniforms. */
private setUniformValuesToControls(uniforms: number[]): void {
- // Populate the control values from the uniforms.
$('#uniformControls > *').forEach((control) => {
(control as unknown as UniformControl).restoreUniformValues(uniforms);
});
}
- private userUniformValuesHaveBeenEdited(): boolean {
- if (this.currentUserUniformValues.length !== this.lastSavedUserUniformValues.length) {
- return true;
- }
- for (let i = 0; i < this.currentUserUniformValues.length; i++) {
- if (this.currentUserUniformValues[i] !== this.lastSavedUserUniformValues[i]) {
- return true;
- }
- }
- return false;
- }
-
- private totalPredefinedUniformValues(): number {
- let ret = 0;
- if (!this.effect) {
- return 0;
- }
- for (let i = 0; i < numPredefinedUniforms; i++) {
- const u = this.effect.getUniform(i);
- ret += u.rows * u.columns;
- }
- return ret;
- }
-
- private setCurrentUserUniformValues(userUniformValues: number[]): void {
- if (this.effect) {
- const uniforms = this.getUniformValuesFromControls();
- // Update only the non-predefined uniform values.
- const begin = this.totalPredefinedUniformValues();
- for (let i = begin; i < this.effect.getUniformFloatCount(); i++) {
- uniforms[i] = userUniformValues[i - begin];
- }
- this.setUniformValuesToControls(uniforms);
- }
- }
-
private getCurrentUserUniformValues(uniforms: number[]): number[] {
- const uniformsArray: number[] = [];
- if (this.effect) {
- // Return only the non-predefined uniform values.
- for (let i = this.totalPredefinedUniformValues(); i < this.effect.getUniformFloatCount(); i++) {
- uniformsArray.push(uniforms[i]);
- }
+ if (this.shaderNode) {
+ return uniforms.slice(this.shaderNode.numPredefinedUniformValues);
}
- return uniformsArray;
+ return [];
+ }
+
+ private getPredefinedUniformValues(uniforms: number[]): number[] {
+ if (this.shaderNode) {
+ return uniforms.slice(0, this.shaderNode.numPredefinedUniformValues);
+ }
+ return [];
}
private drawFrame() {
this.fps.raf();
this.kit!.setCurrentContext(this.canvasKitContext);
const uniformsArray = this.getUniformValuesFromControls();
- this.currentUserUniformValues = this.getCurrentUserUniformValues(uniformsArray);
- // Copy uniforms into this.uniformsMallocObj, which is kept around to avoid
- // copying overhead in WASM.
- if (!this.uniformsMallocObj) {
- this.uniformsMallocObj = this.kit!.Malloc(Float32Array, uniformsArray.length);
- } else if (this.uniformsMallocObj.length !== uniformsArray.length) {
- this.kit!.Free(this.uniformsMallocObj);
- this.uniformsMallocObj = this.kit!.Malloc(Float32Array, uniformsArray.length);
+ // TODO(jcgregorio) Change this to be event driven.
+ this.shaderNode!.currentUserUniformValues = this.getCurrentUserUniformValues(uniformsArray);
+
+ const shader = this.shaderNode!.getShader(this.getPredefinedUniformValues(uniformsArray));
+ if (!shader) {
+ errorMessage('Failed to get shader.', 0);
+ return;
}
- const uniformsFloat32Array: Float32Array = this.uniformsMallocObj.toTypedArray() as Float32Array;
- uniformsArray.forEach((val, index) => { uniformsFloat32Array[index] = val; });
-
- const shader = this.effect!.makeShaderWithChildren(uniformsFloat32Array, false, this.inputImageShaders);
- this._render();
// Allow uniform controls to update, such as uniform-timer-sk.
+ // TODO(jcgregorio) This is overkill, allow controls to register for a 'raf'
+ // event if they need to update frequently.
this._render();
// Draw the shader.
@@ -622,35 +512,13 @@
}
private async runClick() {
- this.startShader(this.editedCode);
+ this.run();
this.saveClick();
}
private async saveClick() {
- const userUniformValues = this.getCurrentUserUniformValues(this.getUniformValuesFromControls());
- const body: ScrapBody = {
- Body: this.editedCode,
- Type: 'sksl',
- SKSLMetaData: {
- Uniforms: userUniformValues,
- Children: [],
- },
- };
try {
- // POST the JSON to /_/upload
- const resp = await fetch('/_/save/', {
- credentials: 'include',
- body: JSON.stringify(body),
- headers: {
- 'Content-Type': 'application/json',
- },
- method: 'POST',
- });
- const json = (await jsonOrThrow(resp)) as ScrapID;
-
- this.state.id = json.Hash;
- this.lastSavedCode = this.editedCode;
- this.lastSavedUserUniformValues = userUniformValues;
+ this.state.id = await this.shaderNode!.saveScrap();
this.stateChanged!();
this._render();
} catch (error) {
@@ -659,7 +527,7 @@
}
private codeChange() {
- this.editedCode = this.codeMirror!.getValue();
+ this.shaderNode!.shaderCode = this.codeMirror!.getValue();
this._render();
}
diff --git a/shaders/modules/shaders-app-sk/shaders-app-sk_test.ts b/shaders/modules/shaders-app-sk/shaders-app-sk_test.ts
deleted file mode 100644
index 1b417f8..0000000
--- a/shaders/modules/shaders-app-sk/shaders-app-sk_test.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import './index';
-import { assert } from 'chai';
-import { ShadersAppSk } from './shaders-app-sk';
-
-import { setUpElementUnderTest } from '../../../infra-sk/modules/test_util';
-
-describe('shaders-app-sk', () => {
- const newInstance = setUpElementUnderTest<ShadersAppSk>('shaders-app-sk');
-
- let element: ShadersAppSk;
- beforeEach(() => {
- element = newInstance();
- });
-
- describe('some action', () => {
- it('some result', () => {
- assert.isNotNull(element);
- });
- });
-});