diff --git a/src/strands/ir_builders.js b/src/strands/ir_builders.js index b9096d0f85..d6d2f46cce 100644 --- a/src/strands/ir_builders.js +++ b/src/strands/ir_builders.js @@ -43,7 +43,7 @@ export function unaryOpNode(strandsContext, nodeOrValue, opCode) { const { dag, cfg } = strandsContext; let dependsOn; let node; - if (nodeOrValue instanceof StrandsNode) { + if (nodeOrValue?.isStrandsNode) { node = nodeOrValue; } else { const { id, dimension } = primitiveConstructorNode(strandsContext, { baseType: BaseType.FLOAT, dimension: null }, nodeOrValue); @@ -257,6 +257,20 @@ export function constructTypeFromIDs(strandsContext, typeInfo, strandsNodesArray export function primitiveConstructorNode(strandsContext, typeInfo, dependsOn) { const cfg = strandsContext.cfg; + dependsOn = (Array.isArray(dependsOn) ? dependsOn : [dependsOn]) + .flat(Infinity) + .map(a => { + if ( + a.isStrandsNode && + a.typeInfo().baseType === BaseType.INT && + // TODO: handle ivec inputs instead of just int scalars + a.typeInfo().dimension === 1 + ) { + return castToFloat(strandsContext, a); + } else { + return a; + } + }); const { mappedDependencies, inferredTypeInfo } = mapPrimitiveDepsToIDs(strandsContext, typeInfo, dependsOn); const finalType = { @@ -272,6 +286,24 @@ export function primitiveConstructorNode(strandsContext, typeInfo, dependsOn) { return { id, dimension: finalType.dimension, components: mappedDependencies }; } +export function castToFloat(strandsContext, dep) { + const { id, dimension } = functionCallNode( + strandsContext, + strandsContext.backend.getTypeName('float', dep.typeInfo().dimension), + [dep], + { + overloads: [{ + params: [dep.typeInfo()], + returnType: { + ...dep.typeInfo(), + baseType: BaseType.FLOAT, + }, + }], + } + ); + return createStrandsNode(id, dimension, strandsContext); +} + export function structConstructorNode(strandsContext, structTypeInfo, rawUserArgs) { const { cfg, dag } = strandsContext; const { identifer, properties } = structTypeInfo; @@ -491,7 +523,7 @@ export function swizzleTrap(id, dimension, strandsContext, onRebind) { // This may not be the most efficient way, as we swizzle each component individually, // so that .xyz becomes .x, .y, .z let scalars = []; - if (value instanceof StrandsNode) { + if (value?.isStrandsNode) { if (value.dimension === 1) { scalars = Array(chars.length).fill(value); } else if (value.dimension === chars.length) { diff --git a/src/strands/strands_api.js b/src/strands/strands_api.js index 03658e9266..fd3c93a6db 100644 --- a/src/strands/strands_api.js +++ b/src/strands/strands_api.js @@ -56,7 +56,7 @@ function _getBuiltinGlobalsCache(strandsContext) { function getBuiltinGlobalNode(strandsContext, name) { const spec = BUILTIN_GLOBAL_SPECS[name] if (!spec) return null - + const cache = _getBuiltinGlobalsCache(strandsContext) const uniformName = `_p5_global_${name}` const cached = cache.nodes.get(uniformName) @@ -154,7 +154,7 @@ export function initGlobalStrandsAPI(p5, fn, strandsContext) { } // Convert value to a StrandsNode if it isn't already - const valueNode = value instanceof StrandsNode ? value : p5.strandsNode(value); + const valueNode = value?.isStrandsNode ? value : p5.strandsNode(value); // Create a new CFG block for the early return const earlyReturnBlockID = CFG.createBasicBlock(cfg, BlockType.DEFAULT); @@ -369,12 +369,17 @@ export function initGlobalStrandsAPI(p5, fn, strandsContext) { fn[typeInfo.fnName] = function(...args) { if (strandsContext.active) { if (args.length === 1 && args[0].dimension && args[0].dimension === typeInfo.dimension) { - const { id, dimension } = build.functionCallNode(strandsContext, typeInfo.fnName, args, { - overloads: [{ - params: [args[0].typeInfo()], - returnType: typeInfo, - }] - }); + const { id, dimension } = build.functionCallNode( + strandsContext, + strandsContext.backend.getTypeName(typeInfo.baseType, typeInfo.dimension), + args, + { + overloads: [{ + params: [args[0].typeInfo()], + returnType: typeInfo, + }] + } + ); return createStrandsNode(id, dimension, strandsContext); } else { // For vector types with a single argument, repeat it for each component @@ -431,7 +436,7 @@ function createHookArguments(strandsContext, parameters){ const oldDependsOn = dag.dependsOn[structNode.id]; const newDependsOn = [...oldDependsOn]; let newValueID; - if (val instanceof StrandsNode) { + if (val?.isStrandsNode) { newValueID = val.id; } else { @@ -463,7 +468,7 @@ function createHookArguments(strandsContext, parameters){ return args; } function enforceReturnTypeMatch(strandsContext, expectedType, returned, hookName) { - if (!(returned instanceof StrandsNode)) { + if (!(returned?.isStrandsNode)) { // try { const result = build.primitiveConstructorNode(strandsContext, expectedType, returned); return result.id; @@ -578,7 +583,7 @@ export function createShaderHooksFunctions(strandsContext, fn, shader) { const handleRetVal = (retNode) => { if(isStructType(expectedReturnType)) { const expectedStructType = structType(expectedReturnType); - if (retNode instanceof StrandsNode) { + if (retNode?.isStrandsNode) { const returnedNode = getNodeDataFromID(strandsContext.dag, retNode.id); if (returnedNode.baseType !== expectedStructType.typeName) { const receivedTypeName = returnedNode.baseType || 'undefined'; diff --git a/src/strands/strands_for.js b/src/strands/strands_for.js index aa57ce669f..22377d45a1 100644 --- a/src/strands/strands_for.js +++ b/src/strands/strands_for.js @@ -309,7 +309,7 @@ export class StrandsFor { let initialVar = this.initialCb(); // Convert to StrandsNode if it's not already one - if (!(initialVar instanceof StrandsNode)) { + if (!(initialVar?.isStrandsNode)) { const { id, dimension } = primitiveConstructorNode(this.strandsContext, { baseType: BaseType.FLOAT, dimension: 1 }, initialVar); initialVar = createStrandsNode(id, dimension, this.strandsContext); } diff --git a/src/strands/strands_node.js b/src/strands/strands_node.js index a181ff608c..582cff37f0 100644 --- a/src/strands/strands_node.js +++ b/src/strands/strands_node.js @@ -40,7 +40,7 @@ export class StrandsNode { const baseType = orig?.baseType ?? BaseType.FLOAT; let newValueID; - if (value instanceof StrandsNode) { + if (value?.isStrandsNode) { newValueID = value.id; } else { const newVal = primitiveConstructorNode( @@ -95,7 +95,7 @@ export class StrandsNode { const baseType = orig?.baseType ?? BaseType.FLOAT; let newValueID; - if (value instanceof StrandsNode) { + if (value?.isStrandsNode) { newValueID = value.id; } else { const newVal = primitiveConstructorNode( diff --git a/src/webgpu/p5.RendererWebGPU.js b/src/webgpu/p5.RendererWebGPU.js index 0eb6635223..312d2e931b 100644 --- a/src/webgpu/p5.RendererWebGPU.js +++ b/src/webgpu/p5.RendererWebGPU.js @@ -1904,7 +1904,7 @@ function rendererWebGPU(p5, fn) { getNextBindingIndex({ vert, frag }, group = 0) { // Get the highest binding index in the specified group and return the next available - const samplerRegex = /@group\((\d+)\)\s*@binding\((\d+)\)\s*var\s+(\w+)\s*:\s*(texture_2d|sampler|uniform)/g; + const samplerRegex = /@group\((\d+)\)\s*@binding\((\d+)\)\s*var(?:)?\s+(\w+)\s*:\s*(texture_2d|sampler|uniform|\w+)/g; let maxBindingIndex = -1; for (const [src, visibility] of [ @@ -2254,6 +2254,9 @@ function rendererWebGPU(p5, fn) { // Inject hook uniforms as a separate struct at a new binding let hookUniformFields = ''; for (const key in shader.hooks.uniforms) { + // Skip textures, they don't get added to structs + if (key.endsWith(': sampler2D')) continue; + // WGSL format: "name: type" hookUniformFields += ` ${key},\n`; } diff --git a/src/webgpu/strands_wgslBackend.js b/src/webgpu/strands_wgslBackend.js index 22ae1d62d7..cca83be343 100644 --- a/src/webgpu/strands_wgslBackend.js +++ b/src/webgpu/strands_wgslBackend.js @@ -223,10 +223,11 @@ export const wgslBackend = { return primitiveTypeName; }, generateHookUniformKey(name, typeInfo) { - // For sampler2D types, we don't add them to the uniform struct - // Instead, they become separate texture and sampler bindings + // For sampler2D types, we don't add them to the uniform struct, + // but we still need them in the shader's hooks object so that + // they can be set by users. if (typeInfo.baseType === 'sampler2D') { - return null; // Signal that this should not be added to uniform struct + return `${name}: sampler2D`; // Signal that this should not be added to uniform struct } return `${name}: ${this.getTypeName(typeInfo.baseType, typeInfo.dimension)}`; }, diff --git a/test/unit/visual/cases/webgl.js b/test/unit/visual/cases/webgl.js index 652f061702..52de295d16 100644 --- a/test/unit/visual/cases/webgl.js +++ b/test/unit/visual/cases/webgl.js @@ -1144,6 +1144,128 @@ visualSuite('WebGL', function() { screenshot(); }); }); + + visualTest('Strands tutorial', function(p5, screenshot) { + // From Luke Plowden's Intro to Strands tutorial + // https://beta.p5js.org/tutorials/intro-to-p5-strands/ + + function starShaderCallback({ p5 }) { + const time = p5.uniformFloat(() => p5.millis()); + const skyRadius = p5.uniformFloat(90); + + function rand2(st) { + return p5.sin((st.x + st.y) * 123.456); + } + + function semiSphere() { + let id = p5.instanceID(); + let theta = rand2([id, 0.1234]) * p5.TWO_PI + time / 100000; + let phi = rand2([id, 3.321]) * p5.PI + time / 50000; + + let r = skyRadius; + r *= p5.sin(phi); + let x = r * p5.sin(phi) * p5.cos(theta); + let y = r * 1.5 * p5.cos(phi); + let z = r * p5.sin(phi) * p5.sin(theta); + return [x, y, z]; + } + + p5.getWorldInputs((inputs) => { + inputs.position += semiSphere(); + return inputs; + }); + + p5.getObjectInputs((inputs) => { + let size = 1 + 0.5 * p5.sin(time * 0.002 + p5.instanceID()); + inputs.position *= size; + return inputs; + }); + } + + function pixelateShaderCallback({ p5 }) { + const pixelCountX = p5.uniformFloat(() => 100); + + p5.getColor((inputs, canvasContent) => { + const aspectRatio = inputs.canvasSize.x / inputs.canvasSize.y; + const pixelSize = [pixelCountX, pixelCountX / aspectRatio]; + + let coord = inputs.texCoord; + coord = p5.floor(coord * pixelSize) / pixelSize; + + let col = p5.getTexture(canvasContent, coord); + return col//[coord, 0, 1]; + }); + } + + function bloomShaderCallback({ p5, originalImage }) { + const preBlur = p5.uniformTexture(() => originalImage); + + getColor((input, canvasContent) => { + const blurredCol = p5.getTexture(canvasContent, input.texCoord); + const originalCol = p5.getTexture(preBlur, input.texCoord); + + const intensity = p5.max(originalCol, 0.1) * 12.2; + + const bloom = originalCol + blurredCol * intensity; + return [bloom.rgb, 1]; + }); + } + + p5.createCanvas(200, 200, p5.WEBGL); + const stars = p5.buildGeometry(() => p5.sphere(4, 4, 2)) + const originalImage = p5.createFramebuffer(); + + function fresnelShaderCallback({ p5 }) { + const fresnelPower = p5.uniformFloat(2); + const fresnelBias = p5.uniformFloat(-0.1); + const fresnelScale = p5.uniformFloat(2); + + p5.getCameraInputs((inputs) => { + let n = p5.normalize(inputs.normal); + let v = p5.normalize(-inputs.position); + let base = 1.0 - p5.dot(n, v); + let fresnel = fresnelScale * p5.pow(base, fresnelPower) + fresnelBias; + let col = p5.mix([0, 0, 0], [1, .5, .7], fresnel); + inputs.color = [col, 1]; + return inputs; + }); + } + + const starShader = p5.baseMaterialShader().modify(starShaderCallback, { p5 }); + const starStrokeShader = p5.baseStrokeShader().modify(starShaderCallback, { p5 }) + const fresnelShader = p5.baseColorShader().modify(fresnelShaderCallback, { p5 }); + const bloomShader = p5.baseFilterShader().modify(bloomShaderCallback, { p5, originalImage }); + const pixelateShader = p5.baseFilterShader().modify(pixelateShaderCallback, { p5 }); + + originalImage.begin(); + p5.background(0); + + p5.push() + p5.strokeWeight(2) + p5.stroke(255,0,0) + p5.fill(255,100, 150) + p5.strokeShader(starStrokeShader) + p5.shader(starShader); + p5.model(stars, 100); + p5.pop() + + p5.push() + p5.shader(fresnelShader) + p5.noStroke() + p5.sphere(30); + p5.filter(pixelateShader); + p5.pop() + + originalImage.end(); + + p5.imageMode(p5.CENTER) + p5.image(originalImage, 0, 0) + + p5.filter(p5.BLUR, 5) + p5.filter(bloomShader); + + screenshot(); + }); }); visualSuite('background()', function () { @@ -1316,7 +1438,7 @@ visualSuite('WebGL', function() { visualSuite('Tessellation', function() { visualTest('Handles nearly identical consecutive vertices', function(p5, screenshot) { p5.createCanvas(400, 400, p5.WEBGL); - + const contours = [ [ [-3.8642425537109375, -6.120738636363637, 0], @@ -1355,7 +1477,7 @@ visualSuite('WebGL', function() { [-1.8045834628018462, 4.177556818181818, 0] ] ]; - + p5.background('red'); p5.push(); p5.stroke(0); diff --git a/test/unit/visual/cases/webgpu.js b/test/unit/visual/cases/webgpu.js index a5351f4d4a..e16c2eb358 100644 --- a/test/unit/visual/cases/webgpu.js +++ b/test/unit/visual/cases/webgpu.js @@ -130,6 +130,128 @@ visualSuite("WebGPU", function () { p5.model(model, 3); await screenshot(); }); + + visualTest('Strands tutorial', async function(p5, screenshot) { + // From Luke Plowden's Intro to Strands tutorial + // https://beta.p5js.org/tutorials/intro-to-p5-strands/ + + function starShaderCallback({ p5 }) { + const time = p5.uniformFloat(() => p5.millis()); + const skyRadius = p5.uniformFloat(90); + + function rand2(st) { + return p5.sin((st.x + st.y) * 123.456); + } + + function semiSphere() { + let id = p5.instanceID(); + let theta = rand2([id, 0.1234]) * p5.TWO_PI + time / 100000; + let phi = rand2([id, 3.321]) * p5.PI + time / 50000; + + let r = skyRadius; + r *= p5.sin(phi); + let x = r * p5.sin(phi) * p5.cos(theta); + let y = r * 1.5 * p5.cos(phi); + let z = r * p5.sin(phi) * p5.sin(theta); + return [x, y, z]; + } + + p5.getWorldInputs((inputs) => { + inputs.position += semiSphere(); + return inputs; + }); + + p5.getObjectInputs((inputs) => { + let size = 1 + 0.5 * p5.sin(time * 0.002 + p5.instanceID()); + inputs.position *= size; + return inputs; + }); + } + + function pixelateShaderCallback({ p5 }) { + const pixelCountX = p5.uniformFloat(() => 100); + + p5.getColor((inputs, canvasContent) => { + const aspectRatio = inputs.canvasSize.x / inputs.canvasSize.y; + const pixelSize = [pixelCountX, pixelCountX / aspectRatio]; + + let coord = inputs.texCoord; + coord = p5.floor(coord * pixelSize) / pixelSize; + + let col = p5.getTexture(canvasContent, coord); + return col//[coord, 0, 1]; + }); + } + + function bloomShaderCallback({ p5, originalImage }) { + const preBlur = p5.uniformTexture(() => originalImage); + + getColor((input, canvasContent) => { + const blurredCol = p5.getTexture(canvasContent, input.texCoord); + const originalCol = p5.getTexture(preBlur, input.texCoord); + + const intensity = p5.max(originalCol, 0.1) * 12.2; + + const bloom = originalCol + blurredCol * intensity; + return [bloom.rgb, 1]; + }); + } + + await p5.createCanvas(200, 200, p5.WEBGPU); + const stars = p5.buildGeometry(() => p5.sphere(4, 4, 2)) + const originalImage = p5.createFramebuffer(); + + function fresnelShaderCallback({ p5 }) { + const fresnelPower = p5.uniformFloat(2); + const fresnelBias = p5.uniformFloat(-0.1); + const fresnelScale = p5.uniformFloat(2); + + p5.getCameraInputs((inputs) => { + let n = p5.normalize(inputs.normal); + let v = p5.normalize(-inputs.position); + let base = 1.0 - p5.dot(n, v); + let fresnel = fresnelScale * p5.pow(base, fresnelPower) + fresnelBias; + let col = p5.mix([0, 0, 0], [1, .5, .7], fresnel); + inputs.color = [col, 1]; + return inputs; + }); + } + + const starShader = p5.baseMaterialShader().modify(starShaderCallback, { p5 }); + const starStrokeShader = p5.baseStrokeShader().modify(starShaderCallback, { p5 }) + const fresnelShader = p5.baseColorShader().modify(fresnelShaderCallback, { p5 }); + const bloomShader = p5.baseFilterShader().modify(bloomShaderCallback, { p5, originalImage }); + const pixelateShader = p5.baseFilterShader().modify(pixelateShaderCallback, { p5 }); + + originalImage.begin(); + p5.background(0); + + p5.push() + p5.strokeWeight(2) + p5.stroke(255,0,0) + p5.fill(255,100, 150) + p5.strokeShader(starStrokeShader) + p5.shader(starShader); + p5.model(stars, 100); + p5.pop() + + p5.push() + p5.shader(fresnelShader) + p5.noStroke() + p5.sphere(30); + p5.filter(pixelateShader); + p5.pop() + + originalImage.end(); + + p5.imageMode(p5.CENTER) + p5.image(originalImage, 0, 0) + + p5.filter(p5.BLUR, 5) + p5.filter(bloomShader); + + await screenshot(); + }); }); visualSuite('filters', function() { diff --git a/test/unit/visual/screenshots/WebGL/p5.strands/Strands tutorial/000.png b/test/unit/visual/screenshots/WebGL/p5.strands/Strands tutorial/000.png new file mode 100644 index 0000000000..bf5bfc4d44 Binary files /dev/null and b/test/unit/visual/screenshots/WebGL/p5.strands/Strands tutorial/000.png differ diff --git a/test/unit/visual/screenshots/WebGL/p5.strands/Strands tutorial/metadata.json b/test/unit/visual/screenshots/WebGL/p5.strands/Strands tutorial/metadata.json new file mode 100644 index 0000000000..2d4bfe30da --- /dev/null +++ b/test/unit/visual/screenshots/WebGL/p5.strands/Strands tutorial/metadata.json @@ -0,0 +1,3 @@ +{ + "numScreenshots": 1 +} \ No newline at end of file diff --git a/test/unit/visual/screenshots/WebGPU/Shaders/Strands tutorial/000.png b/test/unit/visual/screenshots/WebGPU/Shaders/Strands tutorial/000.png new file mode 100644 index 0000000000..e119e64467 Binary files /dev/null and b/test/unit/visual/screenshots/WebGPU/Shaders/Strands tutorial/000.png differ diff --git a/test/unit/visual/screenshots/WebGPU/Shaders/Strands tutorial/metadata.json b/test/unit/visual/screenshots/WebGPU/Shaders/Strands tutorial/metadata.json new file mode 100644 index 0000000000..2d4bfe30da --- /dev/null +++ b/test/unit/visual/screenshots/WebGPU/Shaders/Strands tutorial/metadata.json @@ -0,0 +1,3 @@ +{ + "numScreenshots": 1 +} \ No newline at end of file diff --git a/test/unit/webgpu/p5.RendererWebGPU.js b/test/unit/webgpu/p5.RendererWebGPU.js index 94c426908e..1ed62563c5 100644 --- a/test/unit/webgpu/p5.RendererWebGPU.js +++ b/test/unit/webgpu/p5.RendererWebGPU.js @@ -121,7 +121,7 @@ suite('WebGPU p5.RendererWebGPU', function() { const currentTotalBuffers = poolForVertexBuffer.length + (immediateGeom._vertexBuffersInUse?.vertexBuffer?.length || 0); - expect(currentTotalBuffers).to.equal(initialTotalBuffers, + expect(currentTotalBuffers).to.equal(initialTotalBuffers, `Buffer count should stay constant across frames (frame ${frame})`); } }); @@ -139,11 +139,8 @@ suite('WebGPU p5.RendererWebGPU', function() { try { await p.createCanvas(100, 100, p.WEBGPU); - // This triggers an asynchronous _resetContext - p.setAttributes({ antialias: true }); + await p.setAttributes({ antialias: true }); - // This triggers a synchronous resize() -> _updateSize() - // before the new renderer's device is ready. expect(() => { p.pixelDensity(1); }).not.toThrow();