blob: 7f39dfff4781f9defc5cff9d5a5d83e111c26220 [file] [log] [blame]
/** 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 { errorMessage } from 'elements-sk/errorMessage';
import { Uniform } from '../../../infra-sk/modules/uniform/uniform';
import {
CanvasKit,
Image,
MallocObj, RuntimeEffect, Shader,
} from '../../build/canvaskit/canvaskit';
import { ScrapBody, ScrapID } from '../json';
const DEFAULT_SIZE = 512;
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 resolution (pixels)
uniform shader iImage1; // An input image (Mandrill).`;
/** 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;
/**
* Counts the number of controls that handle pre-defined uniforms.
*
* Takes into account the uniform-fps-sk which doesn't correspond to a uniform.
*/
export const numPredefinedUniformControls = numPredefinedUniforms + 1;
/**
* 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);
}`;
export type callback = ()=> void;
const defaultImageURL = '/dist/mandrill.png';
const defaultBody: ScrapBody = {
Type: 'sksl',
Body: defaultShader,
SKSLMetaData: {
Uniforms: [],
ImageURL: '',
Children: [],
},
};
/** Describes an image used as a shader. */
interface InputImage {
width: number;
height: number;
image: HTMLImageElement;
shader: Shader;
}
/**
* 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 inputImageShader: InputImage | null = null;
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[] = [];
/** The current image being displayed, even if a blob: url. */
private currentImageURL: string = '';
private _numPredefinedUniformValues: number = 0;
constructor(canvasKit: CanvasKit) {
this.canvasKit = canvasKit;
this.inputImageShaderFromCanvasImageSource(new Image(DEFAULT_SIZE, DEFAULT_SIZE));
this.setScrap(defaultBody);
}
/**
* Loads a scrap from the backend for the given scrap id.
*
* The imageLoadedCallback is called once the image has fully loaded.
*/
async loadScrap(scrapID: string, imageLoadedCallback: callback | null = null): Promise<void> {
this.scrapID = scrapID;
const resp = await fetch(`/_/load/${scrapID}`, {
credentials: 'include',
});
const scrapBody = (await jsonOrThrow(resp)) as ScrapBody;
this.setScrap(scrapBody, imageLoadedCallback);
}
/**
* Sets the code and uniforms of a shader to run.
*
* The imageLoadedCallback is called once the image has fully loaded.
*/
setScrap(scrapBody: ScrapBody, imageLoadedCallback: callback | null = null): void {
this.body = scrapBody;
this._shaderCode = this.body.Body;
this.currentUserUniformValues = this.body.SKSLMetaData?.Uniforms || [];
this.setCurrentImageURL(this.body?.SKSLMetaData?.ImageURL || defaultImageURL, imageLoadedCallback);
this.compile();
}
/** Returns a copy of the current ScrapBody for the shader. */
getScrap(): ScrapBody {
return JSON.parse(JSON.stringify(this.body));
}
get inputImageElement(): HTMLImageElement {
return this.inputImageShader!.image;
}
/**
* Don't save or display image URLs that are blob:// or file:// urls.
*/
getSafeImageURL(): string {
if (!this.currentImageURLIsSafe()) {
return this.body?.SKSLMetaData?.ImageURL || defaultImageURL;
}
return this.getCurrentImageURL();
}
/** The current image being used. Note that this could be a blob: URL. */
getCurrentImageURL(): string {
return this.currentImageURL;
}
/**
* Sets the current image to use. Note that if the image fails to load then
* the current image URL will be set to the empty string.
*/
setCurrentImageURL(val: string, imageLoadedCallback: callback | null = null): void {
this.currentImageURL = val;
this.promiseOnImageLoaded(this.currentImageURL).then((imageElement) => {
this.inputImageShaderFromCanvasImageSource(imageElement);
if (imageLoadedCallback) {
imageLoadedCallback();
}
}).catch(() => {
errorMessage(`Failed to load image: ${this.currentImageURL}. Falling back to an empty image.`);
this.currentImageURL = '';
this.inputImageShaderFromCanvasImageSource(new Image(DEFAULT_SIZE, DEFAULT_SIZE));
if (imageLoadedCallback) {
imageLoadedCallback();
}
});
}
/**
* 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,
ImageURL: this.getSafeImageURL(),
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() || this.imageURLHasChanged();
}
/** 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; });
// Write in the iImageResolution uniform values.
const imageResolution = this.findUniform('iImageResolution');
if (imageResolution) {
uniformsFloat32Array[imageResolution.slot] = this.inputImageShader!.width;
uniformsFloat32Array[imageResolution.slot + 1] = this.inputImageShader!.height;
}
return this.effect!.makeShaderWithChildren(uniformsFloat32Array, false, [this.inputImageShader!.shader]);
}
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;
}
private currentImageURLIsSafe() {
const url = new URL(this.currentImageURL, window.location.toString());
if (url.protocol === 'https:' || url.protocol === 'http:') {
return true;
}
return false;
}
private imageURLHasChanged(): boolean {
if (!this.currentImageURLIsSafe()) {
return false;
}
const current = new URL(this.currentImageURL, window.location.toString());
const saved = new URL(this.body?.SKSLMetaData?.ImageURL || '', window.location.toString());
if (current.toString() !== saved.toString()) {
return true;
}
return false;
}
private findUniform(name: string): Uniform | null {
for (let i = 0; i < this.uniforms.length; i++) {
if (name === this.uniforms[i].name) {
return this.uniforms[i];
}
}
return null;
}
private promiseOnImageLoaded(url: string): Promise<HTMLImageElement> {
return new Promise<HTMLImageElement>((resolve, reject) => {
const ele = new Image();
ele.crossOrigin = 'anonymous';
ele.src = url;
if (ele.complete) {
resolve(ele);
} else {
ele.addEventListener('load', () => resolve(ele));
ele.addEventListener('error', (e) => reject(e));
}
});
}
private inputImageShaderFromCanvasImageSource(imageElement: HTMLImageElement): void {
if (this.inputImageShader) {
this.inputImageShader.shader.delete();
}
const image = this.canvasKit!.MakeImageFromCanvasImageSource(imageElement);
this.inputImageShader = {
width: imageElement.naturalWidth,
height: imageElement.naturalHeight,
image: imageElement,
shader: image.makeShaderOptions(this.canvasKit!.TileMode.Clamp, this.canvasKit!.TileMode.Clamp, this.canvasKit!.FilterMode.Linear, this.canvasKit!.MipmapMode.None),
};
image.delete();
}
}