diff --git a/src/color/creating_reading.js b/src/color/creating_reading.js index 7690104406..340887294d 100644 --- a/src/color/creating_reading.js +++ b/src/color/creating_reading.js @@ -132,6 +132,14 @@ function creatingReading(p5, fn){ * The version of `color()` with four parameters interprets them as RGBA, HSBA, * or HSLA colors, depending on the current `colorMode()`. The last parameter * sets the alpha (transparency) value. + * In p5.strands shader callbacks, `color()` accepts the same input + * formats but returns a `vec4` instead of a `p5.Color` object, with + * RGBA components normalized to the 0–1 range. All colors in strands + * are RGB-based; `colorMode()` has no effect inside shader callbacks. + * Color utility functions such as `red()`, `green()`, `blue()`, + * `alpha()`, `hue()`, `saturation()`, `brightness()`, and + * `lightness()` also return values in the 0–1 range when used in + * strands. * * @method color * @param {Number} gray number specifying value between white and black. diff --git a/src/strands/strands_api.js b/src/strands/strands_api.js index 8675dc69b6..1756b5b628 100644 --- a/src/strands/strands_api.js +++ b/src/strands/strands_api.js @@ -289,7 +289,7 @@ export function initGlobalStrandsAPI(p5, fn, strandsContext) { const originalLerp = fn.lerp; augmentFn(fn, p5, 'lerp', function (...args) { if (strandsContext.active) { - return fn.mix(...args); + return this.mix(...args); } else { return originalLerp.apply(this, args); } @@ -312,6 +312,139 @@ export function initGlobalStrandsAPI(p5, fn, strandsContext) { return result; }); + const originalColor = fn.color; + augmentFn(fn, p5, 'color', function (...args) { + if (!strandsContext.active) { + return originalColor.apply(this, args); + } + // Reuse p5's parser - handles hex strings, rgb(), CSS named colors, numerics + const c = originalColor.apply(this, args); + // _getRGBA() returns [r, g, b, a] normalized to 0-1 + const rgba = c._getRGBA(); + const { id, dimension } = build.primitiveConstructorNode( + strandsContext, + { baseType: BaseType.FLOAT, dimension: null }, + rgba + ); + return createStrandsNode(id, dimension, strandsContext); + }); + const originalLerpColor = fn.lerpColor; + augmentFn(fn, p5, 'lerpColor', function (...args) { + if (!strandsContext.active) { + return originalLerpColor.apply(this, args); + } + // In strands, colors are vec4s - lerpColor maps directly to GLSL mix() + return this.mix(...args); + }); + // Component accessors: extract scalar channels from a vec4 color + const originalRed = fn.red; + augmentFn(fn, p5, 'red', function (...args) { + if (!strandsContext.active) { + return originalRed.apply(this, args); + } + return p5.strandsNode(args[0]).x; + }); + + const originalGreen = fn.green; + augmentFn(fn, p5, 'green', function (...args) { + if (!strandsContext.active) { + return originalGreen.apply(this, args); + } + return p5.strandsNode(args[0]).y; + }); + + const originalBlue = fn.blue; + augmentFn(fn, p5, 'blue', function (...args) { + if (!strandsContext.active) { + return originalBlue.apply(this, args); + } + return p5.strandsNode(args[0]).z; + }); + + const originalAlpha = fn.alpha; + augmentFn(fn, p5, 'alpha', function (...args) { + if (!strandsContext.active) { + return originalAlpha.apply(this, args); + } + return p5.strandsNode(args[0]).w; + }); + + // RGB to HSB conversion based on: + // https://en.wikipedia.org/wiki/HSL_and_HSV#From_RGB + // Using mix/step to avoid branching, via the compact form from: + // http://lolengine.net/blog/2013/07/27/rgb-to-hsv-in-glsl + const _rgb2hsb = (instance, colorNode) => { + const r = colorNode.x; + const g = colorNode.y; + const b = colorNode.z; + const K = instance.vec4(0, -1/3, 2/3, -1); + const p = instance.mix( + instance.vec4(b, g, K.w, K.z), + instance.vec4(g, b, K.x, K.y), + instance.step(b, g) + ); + const q = instance.mix( + instance.vec4(p.x, p.y, p.w, r), + instance.vec4(r, p.y, p.z, p.x), + instance.step(p.x, r) + ); + const d = q.x.sub(instance.min(q.w, q.y)); + const e = p5.strandsNode(1e-10); + const h = instance.abs(q.z.add(q.w.sub(q.y).div(d.mult(6).add(e)))); + const s = d.div(q.x.add(e)); + return instance.vec3(h, s, q.x); +}; + +const _rgb2hsl = (instance, colorNode) => { + const r = colorNode.x; + const g = colorNode.y; + const b = colorNode.z; + const maxC = instance.max(r, instance.max(g, b)); + const minC = instance.min(r, instance.min(g, b)); + const l = maxC.add(minC).div(2); + const d = maxC.sub(minC); + const e = p5.strandsNode(1e-10); + const s = instance.mix( + p5.strandsNode(0), + d.div(p5.strandsNode(1).sub(instance.abs(l.mult(2).sub(1)))), + instance.step(e, d) + ); + const h_rg = instance.mod(g.sub(b).div(d.add(e)), p5.strandsNode(6)).div(6); + const h_gb = b.sub(r).div(d.add(e)).add(2).div(6); + const h_br = r.sub(g).div(d.add(e)).add(4).div(6); + const isR = instance.step(maxC.sub(e), r).mult(instance.step(r.sub(e), maxC)); + const isG = instance.step(maxC.sub(e), g).mult(instance.step(g.sub(e), maxC)); + const h = instance.mix(instance.mix(h_br, h_gb, isG), h_rg, isR); + return instance.vec3(h, s, l); +}; + const originalHue = fn.hue; + augmentFn(fn, p5, 'hue', function (...args) { + if (!strandsContext.active) return originalHue.apply(this, args); + const colorNode = p5.strandsNode(args[0]); + return _rgb2hsl(this, this.vec3(colorNode.x, colorNode.y, colorNode.z)).x; + }); + + const originalSaturation = fn.saturation; + augmentFn(fn, p5, 'saturation', function (...args) { + if (!strandsContext.active) return originalSaturation.apply(this, args); + const colorNode = p5.strandsNode(args[0]); + return _rgb2hsl(this, this.vec3(colorNode.x, colorNode.y, colorNode.z)).y; + }); + + const originalBrightness = fn.brightness; + augmentFn(fn, p5, 'brightness', function (...args) { + if (!strandsContext.active) return originalBrightness.apply(this, args); + const colorNode = p5.strandsNode(args[0]); + return _rgb2hsb(this, this.vec3(colorNode.x, colorNode.y, colorNode.z)).z; + }); + + const originalLightness = fn.lightness; + augmentFn(fn, p5, 'lightness', function (...args) { + if (!strandsContext.active) return originalLightness.apply(this, args); + const colorNode = p5.strandsNode(args[0]); + return _rgb2hsl(this, this.vec3(colorNode.x, colorNode.y, colorNode.z)).z; + }); + augmentFn(fn, p5, 'getTexture', function (...rawArgs) { if (strandsContext.active) { const { id, dimension } = strandsContext.backend.createGetTextureCall(strandsContext, rawArgs); diff --git a/test/unit/webgl/p5.Shader.js b/test/unit/webgl/p5.Shader.js index 786540cafa..470bd9fb51 100644 --- a/test/unit/webgl/p5.Shader.js +++ b/test/unit/webgl/p5.Shader.js @@ -631,6 +631,110 @@ test('returns numbers for builtin globals outside hooks and a strandNode when ca assert.approximately(middle[0], 128, 10); assert.approximately(right[0], 204, 10); }); + test('color() with hex string returns correct vec4 in strands', () => { + myp5.createCanvas(50, 50, myp5.WEBGL); + const testShader = myp5.baseMaterialShader().modify(() => { + myp5.getPixelInputs(inputs => { + const c = myp5.color('#ff0000'); + inputs.color = [c.x, c.y, c.z, 1.0]; + return inputs; + }); + }, { myp5 }); + + myp5.noStroke(); + myp5.shader(testShader); + myp5.plane(myp5.width, myp5.height); + + const pixelColor = myp5.get(25, 25); + assert.approximately(pixelColor[0], 255, 5); + assert.approximately(pixelColor[1], 0, 5); + assert.approximately(pixelColor[2], 0, 5); + }); + + test('color() with CSS named color returns correct vec4 in strands', () => { + myp5.createCanvas(50, 50, myp5.WEBGL); + const testShader = myp5.baseMaterialShader().modify(() => { + myp5.getPixelInputs(inputs => { + const c = myp5.color('blue'); + inputs.color = [c.x, c.y, c.z, 1.0]; + return inputs; + }); + }, { myp5 }); + + myp5.noStroke(); + myp5.shader(testShader); + myp5.plane(myp5.width, myp5.height); + + const pixelColor = myp5.get(25, 25); + assert.approximately(pixelColor[0], 0, 5); + assert.approximately(pixelColor[1], 0, 5); + assert.approximately(pixelColor[2], 255, 5); + }); + + test('lerpColor() interpolates between two colors in strands', () => { + myp5.createCanvas(50, 50, myp5.WEBGL); + const testShader = myp5.baseMaterialShader().modify(() => { + myp5.getPixelInputs(inputs => { + const c1 = myp5.color('#ff0000'); + const c2 = myp5.color('#0000ff'); + const mixed = myp5.lerpColor(c1, c2, 0.5); + inputs.color = [mixed.x, mixed.y, mixed.z, 1.0]; + return inputs; + }); + }, { myp5 }); + + myp5.noStroke(); + myp5.shader(testShader); + myp5.plane(myp5.width, myp5.height); + + const pixelColor = myp5.get(25, 25); + assert.approximately(pixelColor[0], 128, 10); + assert.approximately(pixelColor[1], 0, 5); + assert.approximately(pixelColor[2], 128, 10); + }); + + test('red(), green(), blue(), alpha() extract correct channels in strands', () => { + myp5.createCanvas(50, 50, myp5.WEBGL); + const testShader = myp5.baseMaterialShader().modify(() => { + myp5.getPixelInputs(inputs => { + const c = myp5.color('#ff8000'); + const r = myp5.red(c); + const g = myp5.green(c); + const b = myp5.blue(c); + inputs.color = [r, g, b, 1.0]; + return inputs; + }); + }, { myp5 }); + + myp5.noStroke(); + myp5.shader(testShader); + myp5.plane(myp5.width, myp5.height); + + const pixelColor = myp5.get(25, 25); + assert.approximately(pixelColor[0], 255, 5); + assert.approximately(pixelColor[1], 128, 10); + assert.approximately(pixelColor[2], 0, 5); + }); + + test('hue() returns normalized hue value in strands', () => { + myp5.createCanvas(50, 50, myp5.WEBGL); + const testShader = myp5.baseMaterialShader().modify(() => { + myp5.getPixelInputs(inputs => { + // Pure red has hue 0 + const c = myp5.color('#ff0000'); + const h = myp5.hue(c); + inputs.color = [h, h, h, 1.0]; + return inputs; + }); + }, { myp5 }); + + myp5.noStroke(); + myp5.shader(testShader); + myp5.plane(myp5.width, myp5.height); + + const pixelColor = myp5.get(25, 25); + assert.approximately(pixelColor[0], 0, 5); + }); test('handle custom uniform names with automatic values', () => { myp5.createCanvas(50, 50, myp5.WEBGL);