From 2fa995afde7af07f652427256a0147381e313ca2 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Fri, 23 May 2025 16:01:49 -0400 Subject: [PATCH 01/72] Factor out a base 3D renderer --- src/core/p5.Renderer3D.js | 1370 ++++++++++++++++++++++++++++++ src/webgl/p5.RendererGL.js | 1380 +------------------------------ src/webgpu/p5.RendererWebGPU.js | 27 + 3 files changed, 1432 insertions(+), 1345 deletions(-) create mode 100644 src/core/p5.Renderer3D.js create mode 100644 src/webgpu/p5.RendererWebGPU.js diff --git a/src/core/p5.Renderer3D.js b/src/core/p5.Renderer3D.js new file mode 100644 index 0000000000..b9e275c977 --- /dev/null +++ b/src/core/p5.Renderer3D.js @@ -0,0 +1,1370 @@ +import * as constants from "../core/constants"; +import { Renderer } from './p5.Renderer'; +import GeometryBuilder from "../webgl/GeometryBuilder"; +import { Matrix } from "../math/p5.Matrix"; +import { Camera } from "../webgl/p5.Camera"; +import { Vector } from "../math/p5.Vector"; +import { ShapeBuilder } from "../webgl/ShapeBuilder"; +import { GeometryBufferCache } from "../webgl/GeometryBufferCache"; +import { filterParamDefaults } from "../image/const"; +import { PrimitiveToVerticesConverter } from "../shape/custom_shapes"; +import { Color } from "../color/p5.Color"; +import { Element } from "../dom/p5.Element"; +import { Framebuffer } from "../webgl/p5.Framebuffer"; + +export const STROKE_CAP_ENUM = {}; +export const STROKE_JOIN_ENUM = {}; +export let lineDefs = ""; +const defineStrokeCapEnum = function (key, val) { + lineDefs += `#define STROKE_CAP_${key} ${val}\n`; + STROKE_CAP_ENUM[constants[key]] = val; +}; +const defineStrokeJoinEnum = function (key, val) { + lineDefs += `#define STROKE_JOIN_${key} ${val}\n`; + STROKE_JOIN_ENUM[constants[key]] = val; +}; + +// Define constants in line shaders for each type of cap/join, and also record +// the values in JS objects +defineStrokeCapEnum("ROUND", 0); +defineStrokeCapEnum("PROJECT", 1); +defineStrokeCapEnum("SQUARE", 2); +defineStrokeJoinEnum("ROUND", 0); +defineStrokeJoinEnum("MITER", 1); +defineStrokeJoinEnum("BEVEL", 2); + +export class Renderer3D extends Renderer { + constructor(pInst, w, h, isMainCanvas, elt) { + super(pInst, w, h, isMainCanvas); + + // Create new canvas + this.canvas = this.elt = elt || document.createElement("canvas"); + this.setupContext(); + + if (this._isMainCanvas) { + // for pixel method sharing with pimage + this._pInst._curElement = this; + this._pInst.canvas = this.canvas; + } else { + // hide if offscreen buffer by default + this.canvas.style.display = "none"; + } + this.elt.id = "defaultCanvas0"; + this.elt.classList.add("p5Canvas"); + + // Set and return p5.Element + this.wrappedElt = new Element(this.elt, this._pInst); + + // Extend renderer with methods of p5.Element with getters + for (const p of Object.getOwnPropertyNames(Element.prototype)) { + if (p !== 'constructor' && p[0] !== '_') { + Object.defineProperty(this, p, { + get() { + return this.wrappedElt[p]; + } + }) + } + } + + const dimensions = this._adjustDimensions(w, h); + w = dimensions.adjustedWidth; + h = dimensions.adjustedHeight; + + this.width = w; + this.height = h; + + // Set canvas size + this.elt.width = w * this._pixelDensity; + this.elt.height = h * this._pixelDensity; + this.elt.style.width = `${w}px`; + this.elt.style.height = `${h}px`; + this._origViewport = { + width: this.GL.drawingBufferWidth, + height: this.GL.drawingBufferHeight, + }; + this.viewport(this._origViewport.width, this._origViewport.height); + + // Attach canvas element to DOM + if (this._pInst._userNode) { + // user input node case + this._pInst._userNode.appendChild(this.elt); + } else { + //create main element + if (document.getElementsByTagName("main").length === 0) { + let m = document.createElement("main"); + document.body.appendChild(m); + } + //append canvas to main + document.getElementsByTagName("main")[0].appendChild(this.elt); + } + + this.isP3D = true; //lets us know we're in 3d mode + + // When constructing a new Geometry, this will represent the builder + this.geometryBuilder = undefined; + + // Push/pop state + this.states.uModelMatrix = new Matrix(4); + this.states.uViewMatrix = new Matrix(4); + this.states.uPMatrix = new Matrix(4); + + this.states.curCamera = new Camera(this); + this.states.uPMatrix.set(this.states.curCamera.projMatrix); + this.states.uViewMatrix.set(this.states.curCamera.cameraMatrix); + + this.states.enableLighting = false; + this.states.ambientLightColors = []; + this.states.specularColors = [1, 1, 1]; + this.states.directionalLightDirections = []; + this.states.directionalLightDiffuseColors = []; + this.states.directionalLightSpecularColors = []; + this.states.pointLightPositions = []; + this.states.pointLightDiffuseColors = []; + this.states.pointLightSpecularColors = []; + this.states.spotLightPositions = []; + this.states.spotLightDirections = []; + this.states.spotLightDiffuseColors = []; + this.states.spotLightSpecularColors = []; + this.states.spotLightAngle = []; + this.states.spotLightConc = []; + this.states.activeImageLight = null; + + this.states.curFillColor = [1, 1, 1, 1]; + this.states.curAmbientColor = [1, 1, 1, 1]; + this.states.curSpecularColor = [0, 0, 0, 0]; + this.states.curEmissiveColor = [0, 0, 0, 0]; + this.states.curStrokeColor = [0, 0, 0, 1]; + + this.states.curBlendMode = constants.BLEND; + + this.states._hasSetAmbient = false; + this.states._useSpecularMaterial = false; + this.states._useEmissiveMaterial = false; + this.states._useNormalMaterial = false; + this.states._useShininess = 1; + this.states._useMetalness = 0; + + this.states.tint = [255, 255, 255, 255]; + + this.states.constantAttenuation = 1; + this.states.linearAttenuation = 0; + this.states.quadraticAttenuation = 0; + + this.states._currentNormal = new Vector(0, 0, 1); + + this.states.drawMode = constants.FILL; + + this.states._tex = null; + this.states.textureMode = constants.IMAGE; + this.states.textureWrapX = constants.CLAMP; + this.states.textureWrapY = constants.CLAMP; + + // erasing + this._isErasing = false; + + // simple lines + this._simpleLines = false; + + // clipping + this._clipDepths = []; + this._isClipApplied = false; + this._stencilTestOn = false; + + this.mixedAmbientLight = []; + this.mixedSpecularColor = []; + + // p5.framebuffer for this are calculated in getDiffusedTexture function + this.diffusedTextures = new Map(); + // p5.framebuffer for this are calculated in getSpecularTexture function + this.specularTextures = new Map(); + + this.preEraseBlend = undefined; + this._cachedBlendMode = undefined; + this._cachedFillStyle = [1, 1, 1, 1]; + this._cachedStrokeStyle = [0, 0, 0, 1]; + this._isBlending = false; + + this._useLineColor = false; + this._useVertexColor = false; + + this.registerEnabled = new Set(); + + // Camera + this.states.curCamera._computeCameraDefaultSettings(); + this.states.curCamera._setDefaultCamera(); + + // FilterCamera + this.filterCamera = new Camera(this); + this.filterCamera._computeCameraDefaultSettings(); + this.filterCamera._setDefaultCamera(); + // Information about the previous frame's touch object + // for executing orbitControl() + this.prevTouches = []; + // Velocity variable for use with orbitControl() + this.zoomVelocity = 0; + this.rotateVelocity = new Vector(0, 0); + this.moveVelocity = new Vector(0, 0); + // Flags for recording the state of zooming, rotation and moving + this.executeZoom = false; + this.executeRotateAndMove = false; + + this._drawingFilter = false; + this._drawingImage = false; + + this.specularShader = undefined; + this.sphereMapping = undefined; + this.diffusedShader = undefined; + this._baseFilterShader = undefined; + this._defaultLightShader = undefined; + this._defaultImmediateModeShader = undefined; + this._defaultNormalShader = undefined; + this._defaultColorShader = undefined; + this._defaultPointShader = undefined; + + this.states.userFillShader = undefined; + this.states.userStrokeShader = undefined; + this.states.userPointShader = undefined; + this.states.userImageShader = undefined; + + this.states.curveDetail = 1 / 4; + + // Used by beginShape/endShape functions to construct a p5.Geometry + this.shapeBuilder = new ShapeBuilder(this); + + this.geometryBufferCache = new GeometryBufferCache(this); + + this.curStrokeCap = constants.ROUND; + this.curStrokeJoin = constants.ROUND; + + // map of texture sources to textures created in this gl context via this.getTexture(src) + this.textures = new Map(); + + // set of framebuffers in use + this.framebuffers = new Set(); + // stack of active framebuffers + this.activeFramebuffers = []; + + // for post processing step + this.states.filterShader = undefined; + this.filterLayer = undefined; + this.filterLayerTemp = undefined; + this.defaultFilterShaders = {}; + + this.fontInfos = {}; + + this._curShader = undefined; + this.drawShapeCount = 1; + + this.scratchMat3 = new Matrix(3); + } + + remove() { + this.wrappedElt.remove(); + this.wrappedElt = null; + this.canvas = null; + this.elt = null; + } + + ////////////////////////////////////////////// + // Geometry Building + ////////////////////////////////////////////// + + /** + * Starts creating a new p5.Geometry. Subsequent shapes drawn will be added + * to the geometry and then returned when + * endGeometry() is called. One can also use + * buildGeometry() to pass a function that + * draws shapes. + * + * If you need to draw complex shapes every frame which don't change over time, + * combining them upfront with `beginGeometry()` and `endGeometry()` and then + * drawing that will run faster than repeatedly drawing the individual pieces. + * @private + */ + beginGeometry() { + if (this.geometryBuilder) { + throw new Error( + "It looks like `beginGeometry()` is being called while another p5.Geometry is already being build." + ); + } + this.geometryBuilder = new GeometryBuilder(this); + this.geometryBuilder.prevFillColor = this.states.fillColor; + this.fill(new Color([-1, -1, -1, -1])); + } + + /** + * Finishes creating a new p5.Geometry that was + * started using beginGeometry(). One can also + * use buildGeometry() to pass a function that + * draws shapes. + * @private + * + * @returns {p5.Geometry} The model that was built. + */ + endGeometry() { + if (!this.geometryBuilder) { + throw new Error( + "Make sure you call beginGeometry() before endGeometry()!" + ); + } + const geometry = this.geometryBuilder.finish(); + this.fill(this.geometryBuilder.prevFillColor); + this.geometryBuilder = undefined; + return geometry; + } + + /** + * Creates a new p5.Geometry that contains all + * the shapes drawn in a provided callback function. The returned combined shape + * can then be drawn all at once using model(). + * + * If you need to draw complex shapes every frame which don't change over time, + * combining them with `buildGeometry()` once and then drawing that will run + * faster than repeatedly drawing the individual pieces. + * + * One can also draw shapes directly between + * beginGeometry() and + * endGeometry() instead of using a callback + * function. + * @param {Function} callback A function that draws shapes. + * @returns {p5.Geometry} The model that was built from the callback function. + */ + buildGeometry(callback) { + this.beginGeometry(); + callback(); + return this.endGeometry(); + } + + ////////////////////////////////////////////// + // Shape drawing + ////////////////////////////////////////////// + + beginShape(...args) { + super.beginShape(...args); + // TODO remove when shape refactor is complete + // this.shapeBuilder.beginShape(...args); + } + + curveDetail(d) { + if (d === undefined) { + return this.states.curveDetail; + } else { + this.states.setValue("curveDetail", d); + } + } + + drawShape(shape) { + const visitor = new PrimitiveToVerticesConverter({ + curveDetail: this.states.curveDetail, + }); + shape.accept(visitor); + this.shapeBuilder.constructFromContours(shape, visitor.contours); + + if (this.geometryBuilder) { + this.geometryBuilder.addImmediate( + this.shapeBuilder.geometry, + this.shapeBuilder.shapeMode + ); + } else if (this.states.fillColor || this.states.strokeColor) { + if (this.shapeBuilder.shapeMode === constants.POINTS) { + this._drawPoints( + this.shapeBuilder.geometry.vertices, + this.buffers.point + ); + } else { + this._drawGeometry(this.shapeBuilder.geometry, { + mode: this.shapeBuilder.shapeMode, + count: this.drawShapeCount, + }); + } + } + this.drawShapeCount = 1; + } + + endShape(mode, count) { + this.drawShapeCount = count; + super.endShape(mode, count); + } + + vertexProperty(...args) { + this.currentShape.vertexProperty(...args); + } + + normal(xorv, y, z) { + if (xorv instanceof Vector) { + this.states.setValue("_currentNormal", xorv); + } else { + this.states.setValue("_currentNormal", new Vector(xorv, y, z)); + } + this.updateShapeVertexProperties(); + } + + model(model, count = 1) { + if (model.vertices.length > 0) { + if (this.geometryBuilder) { + this.geometryBuilder.addRetained(model); + } else { + if (!this.geometryInHash(model.gid)) { + model._edgesToVertices(); + this._getOrMakeCachedBuffers(model); + } + + this._drawGeometry(model, { count }); + } + } + } + + ////////////////////////////////////////////// + // Rendering + ////////////////////////////////////////////// + + _drawGeometryScaled(model, scaleX, scaleY, scaleZ) { + let originalModelMatrix = this.states.uModelMatrix; + this.states.setValue("uModelMatrix", this.states.uModelMatrix.clone()); + try { + this.states.uModelMatrix.scale(scaleX, scaleY, scaleZ); + + if (this.geometryBuilder) { + this.geometryBuilder.addRetained(model); + } else { + this._drawGeometry(model); + } + } finally { + this.states.setValue("uModelMatrix", originalModelMatrix); + } + } + + _update() { + // reset model view and apply initial camera transform + // (containing only look at info; no projection). + this.states.setValue("uModelMatrix", this.states.uModelMatrix.clone()); + this.states.uModelMatrix.reset(); + this.states.setValue("uViewMatrix", this.states.uViewMatrix.clone()); + this.states.uViewMatrix.set(this.states.curCamera.cameraMatrix); + + // reset light data for new frame. + + this.states.setValue("ambientLightColors", []); + this.states.setValue("specularColors", [1, 1, 1]); + + this.states.setValue("directionalLightDirections", []); + this.states.setValue("directionalLightDiffuseColors", []); + this.states.setValue("directionalLightSpecularColors", []); + + this.states.setValue("pointLightPositions", []); + this.states.setValue("pointLightDiffuseColors", []); + this.states.setValue("pointLightSpecularColors", []); + + this.states.setValue("spotLightPositions", []); + this.states.setValue("spotLightDirections", []); + this.states.setValue("spotLightDiffuseColors", []); + this.states.setValue("spotLightSpecularColors", []); + this.states.setValue("spotLightAngle", []); + this.states.setValue("spotLightConc", []); + + this.states.setValue("enableLighting", false); + + //reset tint value for new frame + this.states.setValue("tint", [255, 255, 255, 255]); + + //Clear depth every frame + this._resetBuffersBeforeDraw() + } + + background(...args) { + const _col = this._pInst.color(...args); + this.clear(..._col._getRGBA()); + } + + ////////////////////////////////////////////// + // Positioning + ////////////////////////////////////////////// + + get uModelMatrix() { + return this.states.uModelMatrix; + } + + get uViewMatrix() { + return this.states.uViewMatrix; + } + + get uPMatrix() { + return this.states.uPMatrix; + } + + get uMVMatrix() { + const m = this.uModelMatrix.copy(); + m.mult(this.uViewMatrix); + return m; + } + + /** + * Get a matrix from world-space to screen-space + */ + getWorldToScreenMatrix() { + const modelMatrix = this.states.uModelMatrix; + const viewMatrix = this.states.uViewMatrix; + const projectionMatrix = this.states.uPMatrix; + const projectedToScreenMatrix = new Matrix(4); + projectedToScreenMatrix.scale(this.width, this.height, 1); + projectedToScreenMatrix.translate([0.5, 0.5, 0.5]); + projectedToScreenMatrix.scale(0.5, -0.5, 0.5); + + const modelViewMatrix = modelMatrix.copy().mult(viewMatrix); + const modelViewProjectionMatrix = modelViewMatrix.mult(projectionMatrix); + const worldToScreenMatrix = modelViewProjectionMatrix.mult(projectedToScreenMatrix); + return worldToScreenMatrix; + } + + ////////////////////////////////////////////// + // COLOR + ////////////////////////////////////////////// + /** + * Basic fill material for geometry with a given color + * @param {Number|Number[]|String|p5.Color} v1 gray value, + * red or hue value (depending on the current color mode), + * or color Array, or CSS color string + * @param {Number} [v2] green or saturation value + * @param {Number} [v3] blue or brightness value + * @param {Number} [a] opacity + * @chainable + * @example + *
+ * + * function setup() { + * createCanvas(200, 200, WEBGL); + * } + * + * function draw() { + * background(0); + * noStroke(); + * fill(100, 100, 240); + * rotateX(frameCount * 0.01); + * rotateY(frameCount * 0.01); + * box(75, 75, 75); + * } + * + *
+ * + * @alt + * black canvas with purple cube spinning + */ + fill(...args) { + super.fill(...args); + //see material.js for more info on color blending in webgl + // const color = fn.color.apply(this._pInst, arguments); + const color = this.states.fillColor; + this.states.setValue("curFillColor", color._array); + this.states.setValue("drawMode", constants.FILL); + this.states.setValue("_useNormalMaterial", false); + this.states.setValue("_tex", null); + } + + /** + * Basic stroke material for geometry with a given color + * @param {Number|Number[]|String|p5.Color} v1 gray value, + * red or hue value (depending on the current color mode), + * or color Array, or CSS color string + * @param {Number} [v2] green or saturation value + * @param {Number} [v3] blue or brightness value + * @param {Number} [a] opacity + * @example + *
+ * + * function setup() { + * createCanvas(200, 200, WEBGL); + * } + * + * function draw() { + * background(0); + * stroke(240, 150, 150); + * fill(100, 100, 240); + * rotateX(frameCount * 0.01); + * rotateY(frameCount * 0.01); + * box(75, 75, 75); + * } + * + *
+ * + * @alt + * black canvas with purple cube with pink outline spinning + */ + stroke(...args) { + super.stroke(...args); + // const color = fn.color.apply(this._pInst, arguments); + this.states.setValue("curStrokeColor", this.states.strokeColor._array); + } + + getCommonVertexProperties() { + return { + ...super.getCommonVertexProperties(), + stroke: this.states.strokeColor, + fill: this.states.fillColor, + normal: this.states._currentNormal, + }; + } + + getSupportedIndividualVertexProperties() { + return { + textureCoordinates: true, + }; + } + + strokeCap(cap) { + this.curStrokeCap = cap; + } + + strokeJoin(join) { + this.curStrokeJoin = join; + } + getFilterLayer() { + if (!this.filterLayer) { + this.filterLayer = new Framebuffer(this); + } + return this.filterLayer; + } + getFilterLayerTemp() { + if (!this.filterLayerTemp) { + this.filterLayerTemp = new Framebuffer(this); + } + return this.filterLayerTemp; + } + matchSize(fboToMatch, target) { + if ( + fboToMatch.width !== target.width || + fboToMatch.height !== target.height + ) { + fboToMatch.resize(target.width, target.height); + } + + if (fboToMatch.pixelDensity() !== target.pixelDensity()) { + fboToMatch.pixelDensity(target.pixelDensity()); + } + } + filter(...args) { + let fbo = this.getFilterLayer(); + + // use internal shader for filter constants BLUR, INVERT, etc + let filterParameter = undefined; + let operation = undefined; + if (typeof args[0] === "string") { + operation = args[0]; + let useDefaultParam = + operation in filterParamDefaults && args[1] === undefined; + filterParameter = useDefaultParam + ? filterParamDefaults[operation] + : args[1]; + + // Create and store shader for constants once on initial filter call. + // Need to store multiple in case user calls different filters, + // eg. filter(BLUR) then filter(GRAY) + if (!(operation in this.defaultFilterShaders)) { + this.defaultFilterShaders[operation] = this._makeFilterShader(fbo.renderer, operation); + } + this.states.setValue( + "filterShader", + this.defaultFilterShaders[operation] + ); + } + // use custom user-supplied shader + else { + this.states.setValue("filterShader", args[0]); + } + + // Setting the target to the framebuffer when applying a filter to a framebuffer. + + const target = this.activeFramebuffer() || this; + + // Resize the framebuffer 'fbo' and adjust its pixel density if it doesn't match the target. + this.matchSize(fbo, target); + + fbo.draw(() => this.clear()); // prevent undesirable feedback effects accumulating secretly. + + let texelSize = [ + 1 / (target.width * target.pixelDensity()), + 1 / (target.height * target.pixelDensity()), + ]; + + // apply blur shader with multiple passes. + if (operation === constants.BLUR) { + // Treating 'tmp' as a framebuffer. + const tmp = this.getFilterLayerTemp(); + // Resize the framebuffer 'tmp' and adjust its pixel density if it doesn't match the target. + this.matchSize(tmp, target); + // setup + this.push(); + this.states.setValue("strokeColor", null); + this.blendMode(constants.BLEND); + + // draw main to temp buffer + this.shader(this.states.filterShader); + this.states.filterShader.setUniform("texelSize", texelSize); + this.states.filterShader.setUniform("canvasSize", [ + target.width, + target.height, + ]); + this.states.filterShader.setUniform( + "radius", + Math.max(1, filterParameter) + ); + + // Horiz pass: draw `target` to `tmp` + tmp.draw(() => { + this.states.filterShader.setUniform("direction", [1, 0]); + this.states.filterShader.setUniform("tex0", target); + this.clear(); + this.shader(this.states.filterShader); + this.noLights(); + this.plane(target.width, target.height); + }); + + // Vert pass: draw `tmp` to `fbo` + fbo.draw(() => { + this.states.filterShader.setUniform("direction", [0, 1]); + this.states.filterShader.setUniform("tex0", tmp); + this.clear(); + this.shader(this.states.filterShader); + this.noLights(); + this.plane(target.width, target.height); + }); + + this.pop(); + } + // every other non-blur shader uses single pass + else { + fbo.draw(() => { + this.states.setValue("strokeColor", null); + this.blendMode(constants.BLEND); + this.shader(this.states.filterShader); + this.states.filterShader.setUniform("tex0", target); + this.states.filterShader.setUniform("texelSize", texelSize); + this.states.filterShader.setUniform("canvasSize", [ + target.width, + target.height, + ]); + // filterParameter uniform only used for POSTERIZE, and THRESHOLD + // but shouldn't hurt to always set + this.states.filterShader.setUniform("filterParameter", filterParameter); + this.noLights(); + this.plane(target.width, target.height); + }); + } + // draw fbo contents onto main renderer. + this.push(); + this.states.setValue("strokeColor", null); + this.clear(); + this.push(); + this.states.setValue("imageMode", constants.CORNER); + this.blendMode(constants.BLEND); + target.filterCamera._resize(); + this.setCamera(target.filterCamera); + this.resetMatrix(); + this._drawingFilter = true; + this.image( + fbo, + 0, + 0, + this.width, + this.height, + -target.width / 2, + -target.height / 2, + target.width, + target.height + ); + this._drawingFilter = false; + this.clearDepth(); + this.pop(); + this.pop(); + } + + // Pass this off to the host instance so that we can treat a renderer and a + // framebuffer the same in filter() + + pixelDensity(newDensity) { + if (newDensity) { + return this._pInst.pixelDensity(newDensity); + } + return this._pInst.pixelDensity(); + } + + blendMode(mode) { + if ( + mode === constants.DARKEST || + mode === constants.LIGHTEST || + mode === constants.ADD || + mode === constants.BLEND || + mode === constants.SUBTRACT || + mode === constants.SCREEN || + mode === constants.EXCLUSION || + mode === constants.REPLACE || + mode === constants.MULTIPLY || + mode === constants.REMOVE + ) + this.states.setValue("curBlendMode", mode); + else if ( + mode === constants.BURN || + mode === constants.OVERLAY || + mode === constants.HARD_LIGHT || + mode === constants.SOFT_LIGHT || + mode === constants.DODGE + ) { + console.warn( + "BURN, OVERLAY, HARD_LIGHT, SOFT_LIGHT, and DODGE only work for blendMode in 2D mode." + ); + } + } + + erase(opacityFill, opacityStroke) { + if (!this._isErasing) { + this.preEraseBlend = this.states.curBlendMode; + this._isErasing = true; + this.blendMode(constants.REMOVE); + this._cachedFillStyle = this.states.curFillColor.slice(); + this.states.setValue("curFillColor", [1, 1, 1, opacityFill / 255]); + this._cachedStrokeStyle = this.states.curStrokeColor.slice(); + this.states.setValue("curStrokeColor", [1, 1, 1, opacityStroke / 255]); + } + } + + noErase() { + if (this._isErasing) { + // Restore colors + this.states.setValue("curFillColor", this._cachedFillStyle.slice()); + this.states.setValue("curStrokeColor", this._cachedStrokeStyle.slice()); + // Restore blend mode + this.states.setValue("curBlendMode", this.preEraseBlend); + this.blendMode(this.preEraseBlend); + // Ensure that _applyBlendMode() sets preEraseBlend back to the original blend mode + this._isErasing = false; + this._applyBlendMode(); + } + } + + drawTarget() { + return this.activeFramebuffers[this.activeFramebuffers.length - 1] || this; + } + + beginClip(options = {}) { + super.beginClip(options); + + this.drawTarget()._isClipApplied = true; + + this._applyClip(); + + this.push(); + this.resetShader(); + if (this.states.fillColor) this.fill(0, 0); + if (this.states.strokeColor) this.stroke(0, 0); + } + + endClip() { + this.pop(); + + this._unapplyClip(); + + // Mark the depth at which the clip has been applied so that we can clear it + // when we pop past this depth + this._clipDepths.push(this._pushPopDepth); + + super.endClip(); + } + + _clearClip() { + this._clearClipBuffer(); + if (this._clipDepths.length > 0) { + this._clipDepths.pop(); + } + this.drawTarget()._isClipApplied = false; + } + + /** + * @private + * @returns {p5.Framebuffer} A p5.Framebuffer set to match the size and settings + * of the renderer's canvas. It will be created if it does not yet exist, and + * reused if it does. + */ + _getTempFramebuffer() { + if (!this._tempFramebuffer) { + this._tempFramebuffer = new Framebuffer(this, { + format: constants.UNSIGNED_BYTE, + useDepth: this._pInst._glAttributes.depth, + depthFormat: constants.UNSIGNED_INT, + antialias: this._pInst._glAttributes.antialias, + }); + } + return this._tempFramebuffer; + } + + ////////////////////////////////////////////// + // HASH | for geometry + ////////////////////////////////////////////// + + geometryInHash(gid) { + return this.geometryBufferCache.isCached(gid); + } + + /** + * [resize description] + * @private + * @param {Number} w [description] + * @param {Number} h [description] + */ + resize(w, h) { + super.resize(w, h); + + // save canvas properties + const props = {}; + for (const key in this.drawingContext) { + const val = this.drawingContext[key]; + if (typeof val !== "object" && typeof val !== "function") { + props[key] = val; + } + } + + const dimensions = this._adjustDimensions(w, h); + w = dimensions.adjustedWidth; + h = dimensions.adjustedHeight; + + this.width = w; + this.height = h; + + this.canvas.width = w * this._pixelDensity; + this.canvas.height = h * this._pixelDensity; + this.canvas.style.width = `${w}px`; + this.canvas.style.height = `${h}px`; + this._updateViewport(); + + this.states.curCamera._resize(); + + //resize pixels buffer + if (typeof this.pixels !== "undefined") { + this._createPixelsArray(); + } + + for (const framebuffer of this.framebuffers) { + // Notify framebuffers of the resize so that any auto-sized framebuffers + // can also update their size + framebuffer._canvasSizeChanged(); + } + + // reset canvas properties + for (const savedKey in props) { + try { + this.drawingContext[savedKey] = props[savedKey]; + } catch (err) { + // ignore read-only property errors + } + } + } + + applyMatrix(a, b, c, d, e, f) { + this.states.setValue("uModelMatrix", this.states.uModelMatrix.clone()); + if (arguments.length === 16) { + // this.states.uModelMatrix.apply(arguments); + Matrix.prototype.apply.apply(this.states.uModelMatrix, arguments); + } else { + this.states.uModelMatrix.apply([ + a, + b, + 0, + 0, + c, + d, + 0, + 0, + 0, + 0, + 1, + 0, + e, + f, + 0, + 1, + ]); + } + } + + /** + * [translate description] + * @private + * @param {Number} x [description] + * @param {Number} y [description] + * @param {Number} z [description] + * @chainable + * @todo implement handle for components or vector as args + */ + translate(x, y, z) { + if (x instanceof Vector) { + z = x.z; + y = x.y; + x = x.x; + } + this.states.setValue("uModelMatrix", this.states.uModelMatrix.clone()); + this.states.uModelMatrix.translate([x, y, z]); + return this; + } + + /** + * Scales the Model View Matrix by a vector + * @private + * @param {Number | p5.Vector | Array} x [description] + * @param {Number} [y] y-axis scalar + * @param {Number} [z] z-axis scalar + * @chainable + */ + scale(x, y, z) { + this.states.setValue("uModelMatrix", this.states.uModelMatrix.clone()); + this.states.uModelMatrix.scale(x, y, z); + return this; + } + + rotate(rad, axis) { + if (typeof axis === "undefined") { + return this.rotateZ(rad); + } + this.states.setValue("uModelMatrix", this.states.uModelMatrix.clone()); + Matrix.prototype.rotate4x4.apply(this.states.uModelMatrix, arguments); + return this; + } + + rotateX(rad) { + this.rotate(rad, 1, 0, 0); + return this; + } + + rotateY(rad) { + this.rotate(rad, 0, 1, 0); + return this; + } + + rotateZ(rad) { + this.rotate(rad, 0, 0, 1); + return this; + } + + pop(...args) { + if ( + this._clipDepths.length > 0 && + this._pushPopDepth === this._clipDepths[this._clipDepths.length - 1] + ) { + this._clearClip(); + if (!this._userEnabledStencil) { + this._internalDisable.call(this.GL, this.GL.STENCIL_TEST); + } + + // Reset saved state + // this._userEnabledStencil = this._savedStencilTestState; + } + super.pop(...args); + this._applyStencilTestIfClipping(); + } + + resetMatrix() { + this.states.setValue("uModelMatrix", this.states.uModelMatrix.clone()); + this.states.uModelMatrix.reset(); + this.states.setValue("uViewMatrix", this.states.uViewMatrix.clone()); + this.states.uViewMatrix.set(this.states.curCamera.cameraMatrix); + return this; + } + + ////////////////////////////////////////////// + // SHADER + ////////////////////////////////////////////// + + _getStrokeShader() { + // select the stroke shader to use + const stroke = this.states.userStrokeShader; + if (stroke) { + return stroke; + } + return this._getLineShader(); + } + + /* + * This method will handle both image shaders and + * fill shaders, returning the appropriate shader + * depending on the current context (image or shape). + */ + _getFillShader() { + // If drawing an image, check for user-defined image shader and filters + if (this._drawingImage) { + // Use user-defined image shader if available and no filter is applied + if (this.states.userImageShader && !this._drawingFilter) { + return this.states.userImageShader; + } else { + return this._getLightShader(); // Fallback to light shader + } + } + // If user has defined a fill shader, return that + else if (this.states.userFillShader) { + return this.states.userFillShader; + } + // Use normal shader if normal material is active + else if (this.states._useNormalMaterial) { + return this._getNormalShader(); + } + // Use light shader if lighting or textures are enabled + else if (this.states.enableLighting || this.states._tex) { + return this._getLightShader(); + } + // Default to color shader if no other conditions are met + return this._getColorShader(); + } + + _getPointShader() { + // select the point shader to use + const point = this.states.userPointShader; + if (!point || !point.isPointShader()) { + return this._getPointShader(); + } + return point; + } + + baseMaterialShader() { + return this._getLightShader(); + } + + baseNormalShader() { + return this._getNormalShader(); + } + + baseColorShader() { + return this._getColorShader(); + } + + /** + * TODO(dave): un-private this when there is a way to actually override the + * shader used for points + * + * Get the shader used when drawing points with `point()`. + * + * You can call `pointShader().modify()` + * and change any of the following hooks: + * - `void beforeVertex`: Called at the start of the vertex shader. + * - `vec3 getLocalPosition`: Update the position of vertices before transforms are applied. It takes in `vec3 position` and must return a modified version. + * - `vec3 getWorldPosition`: Update the position of vertices after transforms are applied. It takes in `vec3 position` and pust return a modified version. + * - `float getPointSize`: Update the size of the point. It takes in `float size` and must return a modified version. + * - `void afterVertex`: Called at the end of the vertex shader. + * - `void beforeFragment`: Called at the start of the fragment shader. + * - `bool shouldDiscard`: Points are drawn inside a square, with the corners discarded in the fragment shader to create a circle. Use this to change this logic. It takes in a `bool willDiscard` and must return a modified version. + * - `vec4 getFinalColor`: Update the final color after mixing. It takes in a `vec4 color` and must return a modified version. + * - `void afterFragment`: Called at the end of the fragment shader. + * + * Call `pointShader().inspectHooks()` to see all the possible hooks and + * their default implementations. + * + * @returns {p5.Shader} The `point()` shader + * @private() + */ + pointShader() { + return this._getPointShader(); + } + + baseStrokeShader() { + return this._getLineShader(); + } + + /** + * @private + * @returns {p5.Framebuffer|null} The currently active framebuffer, or null if + * the main canvas is the current draw target. + */ + activeFramebuffer() { + return this.activeFramebuffers[this.activeFramebuffers.length - 1] || null; + } + + createFramebuffer(options) { + return new Framebuffer(this, options); + } + + _setGlobalUniforms(shader) { + const modelMatrix = this.states.uModelMatrix; + const viewMatrix = this.states.uViewMatrix; + const projectionMatrix = this.states.uPMatrix; + const modelViewMatrix = modelMatrix.copy().mult(viewMatrix); + + shader.setUniform( + "uPerspective", + this.states.curCamera.useLinePerspective ? 1 : 0 + ); + shader.setUniform("uViewMatrix", viewMatrix.mat4); + shader.setUniform("uProjectionMatrix", projectionMatrix.mat4); + shader.setUniform("uModelMatrix", modelMatrix.mat4); + shader.setUniform("uModelViewMatrix", modelViewMatrix.mat4); + if (shader.uniforms.uModelViewProjectionMatrix) { + const modelViewProjectionMatrix = modelViewMatrix.copy(); + modelViewProjectionMatrix.mult(projectionMatrix); + shader.setUniform( + "uModelViewProjectionMatrix", + modelViewProjectionMatrix.mat4 + ); + } + if (shader.uniforms.uNormalMatrix) { + this.scratchMat3.inverseTranspose4x4(modelViewMatrix); + shader.setUniform("uNormalMatrix", this.scratchMat3.mat3); + } + if (shader.uniforms.uModelNormalMatrix) { + this.scratchMat3.inverseTranspose4x4(this.states.uModelMatrix); + shader.setUniform("uModelNormalMatrix", this.scratchMat3.mat3); + } + if (shader.uniforms.uCameraNormalMatrix) { + this.scratchMat3.inverseTranspose4x4(this.states.uViewMatrix); + shader.setUniform("uCameraNormalMatrix", this.scratchMat3.mat3); + } + if (shader.uniforms.uCameraRotation) { + this.scratchMat3.inverseTranspose4x4(this.states.uViewMatrix); + shader.setUniform("uCameraRotation", this.scratchMat3.mat3); + } + shader.setUniform("uViewport", this._viewport); + } + + _setStrokeUniforms(strokeShader) { + // set the uniform values + strokeShader.setUniform("uSimpleLines", this._simpleLines); + strokeShader.setUniform("uUseLineColor", this._useLineColor); + strokeShader.setUniform("uMaterialColor", this.states.curStrokeColor); + strokeShader.setUniform("uStrokeWeight", this.states.strokeWeight); + strokeShader.setUniform("uStrokeCap", STROKE_CAP_ENUM[this.curStrokeCap]); + strokeShader.setUniform( + "uStrokeJoin", + STROKE_JOIN_ENUM[this.curStrokeJoin] + ); + } + + _setFillUniforms(fillShader) { + this.mixedSpecularColor = [...this.states.curSpecularColor]; + const empty = this._getEmptyTexture(); + + if (this.states._useMetalness > 0) { + this.mixedSpecularColor = this.mixedSpecularColor.map( + (mixedSpecularColor, index) => + this.states.curFillColor[index] * this.states._useMetalness + + mixedSpecularColor * (1 - this.states._useMetalness) + ); + } + + // TODO: optimize + fillShader.setUniform("uUseVertexColor", this._useVertexColor); + fillShader.setUniform("uMaterialColor", this.states.curFillColor); + fillShader.setUniform("isTexture", !!this.states._tex); + // We need to explicitly set uSampler back to an empty texture here. + // In general, we record the last set texture so we can re-apply it + // the next time a shader is used. However, the texture() function + // works differently and is global p5 state. If the p5 state has + // been cleared, we also need to clear the value in uSampler to match. + fillShader.setUniform("uSampler", this.states._tex || empty); + fillShader.setUniform("uTint", this.states.tint); + + fillShader.setUniform("uHasSetAmbient", this.states._hasSetAmbient); + fillShader.setUniform("uAmbientMatColor", this.states.curAmbientColor); + fillShader.setUniform("uSpecularMatColor", this.mixedSpecularColor); + fillShader.setUniform("uEmissiveMatColor", this.states.curEmissiveColor); + fillShader.setUniform("uSpecular", this.states._useSpecularMaterial); + fillShader.setUniform("uEmissive", this.states._useEmissiveMaterial); + fillShader.setUniform("uShininess", this.states._useShininess); + fillShader.setUniform("uMetallic", this.states._useMetalness); + + this._setImageLightUniforms(fillShader); + + fillShader.setUniform("uUseLighting", this.states.enableLighting); + + const pointLightCount = this.states.pointLightDiffuseColors.length / 3; + fillShader.setUniform("uPointLightCount", pointLightCount); + fillShader.setUniform( + "uPointLightLocation", + this.states.pointLightPositions + ); + fillShader.setUniform( + "uPointLightDiffuseColors", + this.states.pointLightDiffuseColors + ); + fillShader.setUniform( + "uPointLightSpecularColors", + this.states.pointLightSpecularColors + ); + + const directionalLightCount = + this.states.directionalLightDiffuseColors.length / 3; + fillShader.setUniform("uDirectionalLightCount", directionalLightCount); + fillShader.setUniform( + "uLightingDirection", + this.states.directionalLightDirections + ); + fillShader.setUniform( + "uDirectionalDiffuseColors", + this.states.directionalLightDiffuseColors + ); + fillShader.setUniform( + "uDirectionalSpecularColors", + this.states.directionalLightSpecularColors + ); + + // TODO: sum these here... + const ambientLightCount = this.states.ambientLightColors.length / 3; + this.mixedAmbientLight = [...this.states.ambientLightColors]; + + if (this.states._useMetalness > 0) { + this.mixedAmbientLight = this.mixedAmbientLight.map((ambientColors) => { + let mixing = ambientColors - this.states._useMetalness; + return Math.max(0, mixing); + }); + } + fillShader.setUniform("uAmbientLightCount", ambientLightCount); + fillShader.setUniform("uAmbientColor", this.mixedAmbientLight); + + const spotLightCount = this.states.spotLightDiffuseColors.length / 3; + fillShader.setUniform("uSpotLightCount", spotLightCount); + fillShader.setUniform("uSpotLightAngle", this.states.spotLightAngle); + fillShader.setUniform("uSpotLightConc", this.states.spotLightConc); + fillShader.setUniform( + "uSpotLightDiffuseColors", + this.states.spotLightDiffuseColors + ); + fillShader.setUniform( + "uSpotLightSpecularColors", + this.states.spotLightSpecularColors + ); + fillShader.setUniform("uSpotLightLocation", this.states.spotLightPositions); + fillShader.setUniform( + "uSpotLightDirection", + this.states.spotLightDirections + ); + + fillShader.setUniform( + "uConstantAttenuation", + this.states.constantAttenuation + ); + fillShader.setUniform("uLinearAttenuation", this.states.linearAttenuation); + fillShader.setUniform( + "uQuadraticAttenuation", + this.states.quadraticAttenuation + ); + } + + // getting called from _setFillUniforms + _setImageLightUniforms(shader) { + //set uniform values + shader.setUniform("uUseImageLight", this.states.activeImageLight != null); + // true + if (this.states.activeImageLight) { + // this.states.activeImageLight has image as a key + // look up the texture from the diffusedTexture map + let diffusedLight = this.getDiffusedTexture(this.states.activeImageLight); + shader.setUniform("environmentMapDiffused", diffusedLight); + let specularLight = this.getSpecularTexture(this.states.activeImageLight); + + shader.setUniform("environmentMapSpecular", specularLight); + } + } + + _setPointUniforms(pointShader) { + // set the uniform values + pointShader.setUniform("uMaterialColor", this.states.curStrokeColor); + // @todo is there an instance where this isn't stroke weight? + // should be they be same var? + pointShader.setUniform( + "uPointSize", + this.states.strokeWeight * this._pixelDensity + ); + } +} diff --git a/src/webgl/p5.RendererGL.js b/src/webgl/p5.RendererGL.js index 5e46d2d106..7614dc33af 100644 --- a/src/webgl/p5.RendererGL.js +++ b/src/webgl/p5.RendererGL.js @@ -1,9 +1,5 @@ import * as constants from "../core/constants"; -import GeometryBuilder from "./GeometryBuilder"; -import { Renderer } from "../core/p5.Renderer"; -import { Matrix } from "../math/p5.Matrix"; -import { Camera } from "./p5.Camera"; -import { Vector } from "../math/p5.Vector"; +import { Renderer3D, lineDefs } from "../core/p5.Renderer3D"; import { RenderBuffer } from "./p5.RenderBuffer"; import { DataArray } from "./p5.DataArray"; import { Shader } from "./p5.Shader"; @@ -12,9 +8,6 @@ import { Texture, MipmapTexture } from "./p5.Texture"; import { Framebuffer } from "./p5.Framebuffer"; import { Graphics } from "../core/p5.Graphics"; import { Element } from "../dom/p5.Element"; -import { ShapeBuilder } from "./ShapeBuilder"; -import { GeometryBufferCache } from "./GeometryBufferCache"; -import { filterParamDefaults } from "../image/const"; import filterBaseVert from "./shaders/filters/base.vert"; import lightingShader from "./shaders/lighting.glsl"; @@ -47,29 +40,6 @@ import filterOpaqueFrag from "./shaders/filters/opaque.frag"; import filterInvertFrag from "./shaders/filters/invert.frag"; import filterThresholdFrag from "./shaders/filters/threshold.frag"; import filterShaderVert from "./shaders/filters/default.vert"; -import { PrimitiveToVerticesConverter } from "../shape/custom_shapes"; -import { Color } from "../color/p5.Color"; - -const STROKE_CAP_ENUM = {}; -const STROKE_JOIN_ENUM = {}; -let lineDefs = ""; -const defineStrokeCapEnum = function (key, val) { - lineDefs += `#define STROKE_CAP_${key} ${val}\n`; - STROKE_CAP_ENUM[constants[key]] = val; -}; -const defineStrokeJoinEnum = function (key, val) { - lineDefs += `#define STROKE_JOIN_${key} ${val}\n`; - STROKE_JOIN_ENUM[constants[key]] = val; -}; - -// Define constants in line shaders for each type of cap/join, and also record -// the values in JS objects -defineStrokeCapEnum("ROUND", 0); -defineStrokeCapEnum("PROJECT", 1); -defineStrokeCapEnum("SQUARE", 2); -defineStrokeJoinEnum("ROUND", 0); -defineStrokeJoinEnum("MITER", 1); -defineStrokeJoinEnum("BEVEL", 2); const defaultShaders = { normalVert, @@ -116,212 +86,15 @@ const filterShaderFrags = { * @todo extend class to include public method for offscreen * rendering (FBO). */ -class RendererGL extends Renderer { - constructor(pInst, w, h, isMainCanvas, elt, attr) { - super(pInst, w, h, isMainCanvas); - - // Create new canvas - this.canvas = this.elt = elt || document.createElement("canvas"); - this._setAttributeDefaults(pInst); - this._initContext(); - // This redundant property is useful in reminding you that you are - // interacting with WebGLRenderingContext, still worth considering future removal - this.GL = this.drawingContext; - - if (this._isMainCanvas) { - // for pixel method sharing with pimage - this._pInst._curElement = this; - this._pInst.canvas = this.canvas; - } else { - // hide if offscreen buffer by default - this.canvas.style.display = "none"; - } - this.elt.id = "defaultCanvas0"; - this.elt.classList.add("p5Canvas"); - - // Set and return p5.Element - this.wrappedElt = new Element(this.elt, this._pInst); - - // Extend renderer with methods of p5.Element with getters - for (const p of Object.getOwnPropertyNames(Element.prototype)) { - if (p !== 'constructor' && p[0] !== '_') { - Object.defineProperty(this, p, { - get() { - return this.wrappedElt[p]; - } - }) - } - } - - const dimensions = this._adjustDimensions(w, h); - w = dimensions.adjustedWidth; - h = dimensions.adjustedHeight; - - this.width = w; - this.height = h; - - // Set canvas size - this.elt.width = w * this._pixelDensity; - this.elt.height = h * this._pixelDensity; - this.elt.style.width = `${w}px`; - this.elt.style.height = `${h}px`; - this._origViewport = { - width: this.GL.drawingBufferWidth, - height: this.GL.drawingBufferHeight, - }; - this.viewport(this._origViewport.width, this._origViewport.height); - - // Attach canvas element to DOM - if (this._pInst._userNode) { - // user input node case - this._pInst._userNode.appendChild(this.elt); - } else { - //create main element - if (document.getElementsByTagName("main").length === 0) { - let m = document.createElement("main"); - document.body.appendChild(m); - } - //append canvas to main - document.getElementsByTagName("main")[0].appendChild(this.elt); - } +class RendererGL extends Renderer3D { + constructor(pInst, w, h, isMainCanvas, elt) { + super(pInst, w, h, isMainCanvas, elt); - this.isP3D = true; //lets us know we're in 3d mode - - // When constructing a new Geometry, this will represent the builder - this.geometryBuilder = undefined; - - // Push/pop state - this.states.uModelMatrix = new Matrix(4); - this.states.uViewMatrix = new Matrix(4); - this.states.uPMatrix = new Matrix(4); - - this.states.curCamera = new Camera(this); - this.states.uPMatrix.set(this.states.curCamera.projMatrix); - this.states.uViewMatrix.set(this.states.curCamera.cameraMatrix); - - this.states.enableLighting = false; - this.states.ambientLightColors = []; - this.states.specularColors = [1, 1, 1]; - this.states.directionalLightDirections = []; - this.states.directionalLightDiffuseColors = []; - this.states.directionalLightSpecularColors = []; - this.states.pointLightPositions = []; - this.states.pointLightDiffuseColors = []; - this.states.pointLightSpecularColors = []; - this.states.spotLightPositions = []; - this.states.spotLightDirections = []; - this.states.spotLightDiffuseColors = []; - this.states.spotLightSpecularColors = []; - this.states.spotLightAngle = []; - this.states.spotLightConc = []; - this.states.activeImageLight = null; - - this.states.curFillColor = [1, 1, 1, 1]; - this.states.curAmbientColor = [1, 1, 1, 1]; - this.states.curSpecularColor = [0, 0, 0, 0]; - this.states.curEmissiveColor = [0, 0, 0, 0]; - this.states.curStrokeColor = [0, 0, 0, 1]; - - this.states.curBlendMode = constants.BLEND; - - this.states._hasSetAmbient = false; - this.states._useSpecularMaterial = false; - this.states._useEmissiveMaterial = false; - this.states._useNormalMaterial = false; - this.states._useShininess = 1; - this.states._useMetalness = 0; - - this.states.tint = [255, 255, 255, 255]; - - this.states.constantAttenuation = 1; - this.states.linearAttenuation = 0; - this.states.quadraticAttenuation = 0; - - this.states._currentNormal = new Vector(0, 0, 1); - - this.states.drawMode = constants.FILL; - - this.states._tex = null; - this.states.textureMode = constants.IMAGE; - this.states.textureWrapX = constants.CLAMP; - this.states.textureWrapY = constants.CLAMP; - - // erasing - this._isErasing = false; - - // simple lines - this._simpleLines = false; - - // clipping - this._clipDepths = []; - this._isClipApplied = false; - this._stencilTestOn = false; - - this.mixedAmbientLight = []; - this.mixedSpecularColor = []; - - // p5.framebuffer for this are calculated in getDiffusedTexture function - this.diffusedTextures = new Map(); - // p5.framebuffer for this are calculated in getSpecularTexture function - this.specularTextures = new Map(); - - this.preEraseBlend = undefined; - this._cachedBlendMode = undefined; - this._cachedFillStyle = [1, 1, 1, 1]; - this._cachedStrokeStyle = [0, 0, 0, 1]; if (this.webglVersion === constants.WEBGL2) { this.blendExt = this.GL; } else { this.blendExt = this.GL.getExtension("EXT_blend_minmax"); } - this._isBlending = false; - - this._useLineColor = false; - this._useVertexColor = false; - - this.registerEnabled = new Set(); - - // Camera - this.states.curCamera._computeCameraDefaultSettings(); - this.states.curCamera._setDefaultCamera(); - - // FilterCamera - this.filterCamera = new Camera(this); - this.filterCamera._computeCameraDefaultSettings(); - this.filterCamera._setDefaultCamera(); - // Information about the previous frame's touch object - // for executing orbitControl() - this.prevTouches = []; - // Velocity variable for use with orbitControl() - this.zoomVelocity = 0; - this.rotateVelocity = new Vector(0, 0); - this.moveVelocity = new Vector(0, 0); - // Flags for recording the state of zooming, rotation and moving - this.executeZoom = false; - this.executeRotateAndMove = false; - - this._drawingFilter = false; - this._drawingImage = false; - - this.specularShader = undefined; - this.sphereMapping = undefined; - this.diffusedShader = undefined; - this._baseFilterShader = undefined; - this._defaultLightShader = undefined; - this._defaultImmediateModeShader = undefined; - this._defaultNormalShader = undefined; - this._defaultColorShader = undefined; - this._defaultPointShader = undefined; - - this.states.userFillShader = undefined; - this.states.userStrokeShader = undefined; - this.states.userPointShader = undefined; - this.states.userImageShader = undefined; - - this.states.curveDetail = 1 / 4; - - // Used by beginShape/endShape functions to construct a p5.Geometry - this.shapeBuilder = new ShapeBuilder(this); this.buffers = { fill: [ @@ -407,32 +180,6 @@ class RendererGL extends Renderer { user: [], }; - this.geometryBufferCache = new GeometryBufferCache(this); - - this.curStrokeCap = constants.ROUND; - this.curStrokeJoin = constants.ROUND; - - // map of texture sources to textures created in this gl context via this.getTexture(src) - this.textures = new Map(); - - // set of framebuffers in use - this.framebuffers = new Set(); - // stack of active framebuffers - this.activeFramebuffers = []; - - // for post processing step - this.states.filterShader = undefined; - this.filterLayer = undefined; - this.filterLayerTemp = undefined; - this.defaultFilterShaders = {}; - - this.fontInfos = {}; - - this._curShader = undefined; - this.drawShapeCount = 1; - - this.scratchMat3 = new Matrix(3); - this._userEnabledStencil = false; // Store original methods for internal use this._internalEnable = this.drawingContext.enable; @@ -457,160 +204,12 @@ class RendererGL extends Renderer { }; } - remove() { - this.wrappedElt.remove(); - this.wrappedElt = null; - this.canvas = null; - this.elt = null; - } - - ////////////////////////////////////////////// - // Geometry Building - ////////////////////////////////////////////// - - /** - * Starts creating a new p5.Geometry. Subsequent shapes drawn will be added - * to the geometry and then returned when - * endGeometry() is called. One can also use - * buildGeometry() to pass a function that - * draws shapes. - * - * If you need to draw complex shapes every frame which don't change over time, - * combining them upfront with `beginGeometry()` and `endGeometry()` and then - * drawing that will run faster than repeatedly drawing the individual pieces. - * @private - */ - beginGeometry() { - if (this.geometryBuilder) { - throw new Error( - "It looks like `beginGeometry()` is being called while another p5.Geometry is already being build." - ); - } - this.geometryBuilder = new GeometryBuilder(this); - this.geometryBuilder.prevFillColor = this.states.fillColor; - this.fill(new Color([-1, -1, -1, -1])); - } - - /** - * Finishes creating a new p5.Geometry that was - * started using beginGeometry(). One can also - * use buildGeometry() to pass a function that - * draws shapes. - * @private - * - * @returns {p5.Geometry} The model that was built. - */ - endGeometry() { - if (!this.geometryBuilder) { - throw new Error( - "Make sure you call beginGeometry() before endGeometry()!" - ); - } - const geometry = this.geometryBuilder.finish(); - this.fill(this.geometryBuilder.prevFillColor); - this.geometryBuilder = undefined; - return geometry; - } - - /** - * Creates a new p5.Geometry that contains all - * the shapes drawn in a provided callback function. The returned combined shape - * can then be drawn all at once using model(). - * - * If you need to draw complex shapes every frame which don't change over time, - * combining them with `buildGeometry()` once and then drawing that will run - * faster than repeatedly drawing the individual pieces. - * - * One can also draw shapes directly between - * beginGeometry() and - * endGeometry() instead of using a callback - * function. - * @param {Function} callback A function that draws shapes. - * @returns {p5.Geometry} The model that was built from the callback function. - */ - buildGeometry(callback) { - this.beginGeometry(); - callback(); - return this.endGeometry(); - } - - ////////////////////////////////////////////// - // Shape drawing - ////////////////////////////////////////////// - - beginShape(...args) { - super.beginShape(...args); - // TODO remove when shape refactor is complete - // this.shapeBuilder.beginShape(...args); - } - - curveDetail(d) { - if (d === undefined) { - return this.states.curveDetail; - } else { - this.states.setValue("curveDetail", d); - } - } - - drawShape(shape) { - const visitor = new PrimitiveToVerticesConverter({ - curveDetail: this.states.curveDetail, - }); - shape.accept(visitor); - this.shapeBuilder.constructFromContours(shape, visitor.contours); - - if (this.geometryBuilder) { - this.geometryBuilder.addImmediate( - this.shapeBuilder.geometry, - this.shapeBuilder.shapeMode - ); - } else if (this.states.fillColor || this.states.strokeColor) { - if (this.shapeBuilder.shapeMode === constants.POINTS) { - this._drawPoints( - this.shapeBuilder.geometry.vertices, - this.buffers.point - ); - } else { - this._drawGeometry(this.shapeBuilder.geometry, { - mode: this.shapeBuilder.shapeMode, - count: this.drawShapeCount, - }); - } - } - this.drawShapeCount = 1; - } - - endShape(mode, count) { - this.drawShapeCount = count; - super.endShape(mode, count); - } - - vertexProperty(...args) { - this.currentShape.vertexProperty(...args); - } - - normal(xorv, y, z) { - if (xorv instanceof Vector) { - this.states.setValue("_currentNormal", xorv); - } else { - this.states.setValue("_currentNormal", new Vector(xorv, y, z)); - } - this.updateShapeVertexProperties(); - } - - model(model, count = 1) { - if (model.vertices.length > 0) { - if (this.geometryBuilder) { - this.geometryBuilder.addRetained(model); - } else { - if (!this.geometryInHash(model.gid)) { - model._edgesToVertices(); - this._getOrMakeCachedBuffers(model); - } - - this._drawGeometry(model, { count }); - } - } + setupContext() { + this._setAttributeDefaults(this._pInst); + this._initContext(); + // This redundant property is useful in reminding you that you are + // interacting with WebGLRenderingContext, still worth considering future removal + this.GL = this.drawingContext; } ////////////////////////////////////////////// @@ -646,22 +245,6 @@ class RendererGL extends Renderer { this.buffers.user = []; } - _drawGeometryScaled(model, scaleX, scaleY, scaleZ) { - let originalModelMatrix = this.states.uModelMatrix; - this.states.setValue("uModelMatrix", this.states.uModelMatrix.clone()); - try { - this.states.uModelMatrix.scale(scaleX, scaleY, scaleZ); - - if (this.geometryBuilder) { - this.geometryBuilder.addRetained(model); - } else { - this._drawGeometry(model); - } - } finally { - this.states.setValue("uModelMatrix", originalModelMatrix); - } - } - _drawFills(geometry, { count, mode } = {}) { this._useVertexColor = geometry.vertexColors.length > 0; @@ -1004,433 +587,15 @@ class RendererGL extends Renderer { } } - _update() { - // reset model view and apply initial camera transform - // (containing only look at info; no projection). - this.states.setValue("uModelMatrix", this.states.uModelMatrix.clone()); - this.states.uModelMatrix.reset(); - this.states.setValue("uViewMatrix", this.states.uViewMatrix.clone()); - this.states.uViewMatrix.set(this.states.curCamera.cameraMatrix); - - // reset light data for new frame. - - this.states.setValue("ambientLightColors", []); - this.states.setValue("specularColors", [1, 1, 1]); - - this.states.setValue("directionalLightDirections", []); - this.states.setValue("directionalLightDiffuseColors", []); - this.states.setValue("directionalLightSpecularColors", []); - - this.states.setValue("pointLightPositions", []); - this.states.setValue("pointLightDiffuseColors", []); - this.states.setValue("pointLightSpecularColors", []); - - this.states.setValue("spotLightPositions", []); - this.states.setValue("spotLightDirections", []); - this.states.setValue("spotLightDiffuseColors", []); - this.states.setValue("spotLightSpecularColors", []); - this.states.setValue("spotLightAngle", []); - this.states.setValue("spotLightConc", []); - - this.states.setValue("enableLighting", false); - - //reset tint value for new frame - this.states.setValue("tint", [255, 255, 255, 255]); - - //Clear depth every frame + _resetBuffersBeforeDraw() { this.GL.clearStencil(0); this.GL.clear(this.GL.DEPTH_BUFFER_BIT | this.GL.STENCIL_BUFFER_BIT); if (!this._userEnabledStencil) { this._internalDisable.call(this.GL, this.GL.STENCIL_TEST); } - - } - - /** - * [background description] - */ - background(...args) { - const _col = this._pInst.color(...args); - this.clear(..._col._getRGBA()); - } - - ////////////////////////////////////////////// - // Positioning - ////////////////////////////////////////////// - - get uModelMatrix() { - return this.states.uModelMatrix; - } - - get uViewMatrix() { - return this.states.uViewMatrix; - } - - get uPMatrix() { - return this.states.uPMatrix; - } - - get uMVMatrix() { - const m = this.uModelMatrix.copy(); - m.mult(this.uViewMatrix); - return m; - } - - /** - * Get a matrix from world-space to screen-space - */ - getWorldToScreenMatrix() { - const modelMatrix = this.states.uModelMatrix; - const viewMatrix = this.states.uViewMatrix; - const projectionMatrix = this.states.uPMatrix; - const projectedToScreenMatrix = new Matrix(4); - projectedToScreenMatrix.scale(this.width, this.height, 1); - projectedToScreenMatrix.translate([0.5, 0.5, 0.5]); - projectedToScreenMatrix.scale(0.5, -0.5, 0.5); - - const modelViewMatrix = modelMatrix.copy().mult(viewMatrix); - const modelViewProjectionMatrix = modelViewMatrix.mult(projectionMatrix); - const worldToScreenMatrix = modelViewProjectionMatrix.mult(projectedToScreenMatrix); - return worldToScreenMatrix; - } - - ////////////////////////////////////////////// - // COLOR - ////////////////////////////////////////////// - /** - * Basic fill material for geometry with a given color - * @param {Number|Number[]|String|p5.Color} v1 gray value, - * red or hue value (depending on the current color mode), - * or color Array, or CSS color string - * @param {Number} [v2] green or saturation value - * @param {Number} [v3] blue or brightness value - * @param {Number} [a] opacity - * @chainable - * @example - *
- * - * function setup() { - * createCanvas(200, 200, WEBGL); - * } - * - * function draw() { - * background(0); - * noStroke(); - * fill(100, 100, 240); - * rotateX(frameCount * 0.01); - * rotateY(frameCount * 0.01); - * box(75, 75, 75); - * } - * - *
- * - * @alt - * black canvas with purple cube spinning - */ - fill(...args) { - super.fill(...args); - //see material.js for more info on color blending in webgl - // const color = fn.color.apply(this._pInst, arguments); - const color = this.states.fillColor; - this.states.setValue("curFillColor", color._array); - this.states.setValue("drawMode", constants.FILL); - this.states.setValue("_useNormalMaterial", false); - this.states.setValue("_tex", null); - } - - /** - * Basic stroke material for geometry with a given color - * @param {Number|Number[]|String|p5.Color} v1 gray value, - * red or hue value (depending on the current color mode), - * or color Array, or CSS color string - * @param {Number} [v2] green or saturation value - * @param {Number} [v3] blue or brightness value - * @param {Number} [a] opacity - * @example - *
- * - * function setup() { - * createCanvas(200, 200, WEBGL); - * } - * - * function draw() { - * background(0); - * stroke(240, 150, 150); - * fill(100, 100, 240); - * rotateX(frameCount * 0.01); - * rotateY(frameCount * 0.01); - * box(75, 75, 75); - * } - * - *
- * - * @alt - * black canvas with purple cube with pink outline spinning - */ - stroke(...args) { - super.stroke(...args); - // const color = fn.color.apply(this._pInst, arguments); - this.states.setValue("curStrokeColor", this.states.strokeColor._array); - } - - getCommonVertexProperties() { - return { - ...super.getCommonVertexProperties(), - stroke: this.states.strokeColor, - fill: this.states.fillColor, - normal: this.states._currentNormal, - }; - } - - getSupportedIndividualVertexProperties() { - return { - textureCoordinates: true, - }; - } - - strokeCap(cap) { - this.curStrokeCap = cap; - } - - strokeJoin(join) { - this.curStrokeJoin = join; - } - getFilterLayer() { - if (!this.filterLayer) { - this.filterLayer = new Framebuffer(this); - } - return this.filterLayer; } - getFilterLayerTemp() { - if (!this.filterLayerTemp) { - this.filterLayerTemp = new Framebuffer(this); - } - return this.filterLayerTemp; - } - matchSize(fboToMatch, target) { - if ( - fboToMatch.width !== target.width || - fboToMatch.height !== target.height - ) { - fboToMatch.resize(target.width, target.height); - } - - if (fboToMatch.pixelDensity() !== target.pixelDensity()) { - fboToMatch.pixelDensity(target.pixelDensity()); - } - } - filter(...args) { - let fbo = this.getFilterLayer(); - - // use internal shader for filter constants BLUR, INVERT, etc - let filterParameter = undefined; - let operation = undefined; - if (typeof args[0] === "string") { - operation = args[0]; - let useDefaultParam = - operation in filterParamDefaults && args[1] === undefined; - filterParameter = useDefaultParam - ? filterParamDefaults[operation] - : args[1]; - - // Create and store shader for constants once on initial filter call. - // Need to store multiple in case user calls different filters, - // eg. filter(BLUR) then filter(GRAY) - if (!(operation in this.defaultFilterShaders)) { - this.defaultFilterShaders[operation] = new Shader( - fbo.renderer, - filterShaderVert, - filterShaderFrags[operation] - ); - } - this.states.setValue( - "filterShader", - this.defaultFilterShaders[operation] - ); - } - // use custom user-supplied shader - else { - this.states.setValue("filterShader", args[0]); - } - - // Setting the target to the framebuffer when applying a filter to a framebuffer. - - const target = this.activeFramebuffer() || this; - - // Resize the framebuffer 'fbo' and adjust its pixel density if it doesn't match the target. - this.matchSize(fbo, target); - - fbo.draw(() => this.clear()); // prevent undesirable feedback effects accumulating secretly. - - let texelSize = [ - 1 / (target.width * target.pixelDensity()), - 1 / (target.height * target.pixelDensity()), - ]; - - // apply blur shader with multiple passes. - if (operation === constants.BLUR) { - // Treating 'tmp' as a framebuffer. - const tmp = this.getFilterLayerTemp(); - // Resize the framebuffer 'tmp' and adjust its pixel density if it doesn't match the target. - this.matchSize(tmp, target); - // setup - this.push(); - this.states.setValue("strokeColor", null); - this.blendMode(constants.BLEND); - - // draw main to temp buffer - this.shader(this.states.filterShader); - this.states.filterShader.setUniform("texelSize", texelSize); - this.states.filterShader.setUniform("canvasSize", [ - target.width, - target.height, - ]); - this.states.filterShader.setUniform( - "radius", - Math.max(1, filterParameter) - ); - - // Horiz pass: draw `target` to `tmp` - tmp.draw(() => { - this.states.filterShader.setUniform("direction", [1, 0]); - this.states.filterShader.setUniform("tex0", target); - this.clear(); - this.shader(this.states.filterShader); - this.noLights(); - this.plane(target.width, target.height); - }); - - // Vert pass: draw `tmp` to `fbo` - fbo.draw(() => { - this.states.filterShader.setUniform("direction", [0, 1]); - this.states.filterShader.setUniform("tex0", tmp); - this.clear(); - this.shader(this.states.filterShader); - this.noLights(); - this.plane(target.width, target.height); - }); - - this.pop(); - } - // every other non-blur shader uses single pass - else { - fbo.draw(() => { - this.states.setValue("strokeColor", null); - this.blendMode(constants.BLEND); - this.shader(this.states.filterShader); - this.states.filterShader.setUniform("tex0", target); - this.states.filterShader.setUniform("texelSize", texelSize); - this.states.filterShader.setUniform("canvasSize", [ - target.width, - target.height, - ]); - // filterParameter uniform only used for POSTERIZE, and THRESHOLD - // but shouldn't hurt to always set - this.states.filterShader.setUniform("filterParameter", filterParameter); - this.noLights(); - this.plane(target.width, target.height); - }); - } - // draw fbo contents onto main renderer. - this.push(); - this.states.setValue("strokeColor", null); - this.clear(); - this.push(); - this.states.setValue("imageMode", constants.CORNER); - this.blendMode(constants.BLEND); - target.filterCamera._resize(); - this.setCamera(target.filterCamera); - this.resetMatrix(); - this._drawingFilter = true; - this.image( - fbo, - 0, - 0, - this.width, - this.height, - -target.width / 2, - -target.height / 2, - target.width, - target.height - ); - this._drawingFilter = false; - this.clearDepth(); - this.pop(); - this.pop(); - } - - // Pass this off to the host instance so that we can treat a renderer and a - // framebuffer the same in filter() - - pixelDensity(newDensity) { - if (newDensity) { - return this._pInst.pixelDensity(newDensity); - } - return this._pInst.pixelDensity(); - } - - blendMode(mode) { - if ( - mode === constants.DARKEST || - mode === constants.LIGHTEST || - mode === constants.ADD || - mode === constants.BLEND || - mode === constants.SUBTRACT || - mode === constants.SCREEN || - mode === constants.EXCLUSION || - mode === constants.REPLACE || - mode === constants.MULTIPLY || - mode === constants.REMOVE - ) - this.states.setValue("curBlendMode", mode); - else if ( - mode === constants.BURN || - mode === constants.OVERLAY || - mode === constants.HARD_LIGHT || - mode === constants.SOFT_LIGHT || - mode === constants.DODGE - ) { - console.warn( - "BURN, OVERLAY, HARD_LIGHT, SOFT_LIGHT, and DODGE only work for blendMode in 2D mode." - ); - } - } - - erase(opacityFill, opacityStroke) { - if (!this._isErasing) { - this.preEraseBlend = this.states.curBlendMode; - this._isErasing = true; - this.blendMode(constants.REMOVE); - this._cachedFillStyle = this.states.curFillColor.slice(); - this.states.setValue("curFillColor", [1, 1, 1, opacityFill / 255]); - this._cachedStrokeStyle = this.states.curStrokeColor.slice(); - this.states.setValue("curStrokeColor", [1, 1, 1, opacityStroke / 255]); - } - } - - noErase() { - if (this._isErasing) { - // Restore colors - this.states.setValue("curFillColor", this._cachedFillStyle.slice()); - this.states.setValue("curStrokeColor", this._cachedStrokeStyle.slice()); - // Restore blend mode - this.states.setValue("curBlendMode", this.preEraseBlend); - this.blendMode(this.preEraseBlend); - // Ensure that _applyBlendMode() sets preEraseBlend back to the original blend mode - this._isErasing = false; - this._applyBlendMode(); - } - } - - drawTarget() { - return this.activeFramebuffers[this.activeFramebuffers.length - 1] || this; - } - - beginClip(options = {}) { - super.beginClip(options); - - this.drawTarget()._isClipApplied = true; + _applyClip() { const gl = this.GL; gl.clearStencil(0); gl.clear(gl.STENCIL_BUFFER_BIT); @@ -1447,16 +612,9 @@ class RendererGL extends Renderer { gl.REPLACE // what to do if both tests pass ); gl.disable(gl.DEPTH_TEST); - - this.push(); - this.resetShader(); - if (this.states.fillColor) this.fill(0, 0); - if (this.states.strokeColor) this.stroke(0, 0); } - endClip() { - this.pop(); - + _unapplyClip() { const gl = this.GL; gl.stencilOp( gl.KEEP, // what to do if the stencil test fails @@ -1469,21 +627,11 @@ class RendererGL extends Renderer { 0xff // mask ); gl.enable(gl.DEPTH_TEST); - - // Mark the depth at which the clip has been applied so that we can clear it - // when we pop past this depth - this._clipDepths.push(this._pushPopDepth); - - super.endClip(); } - _clearClip() { + _clearClipBuffer() { this.GL.clearStencil(1); this.GL.clear(this.GL.STENCIL_BUFFER_BIT); - if (this._clipDepths.length > 0) { - this._clipDepths.pop(); - } - this.drawTarget()._isClipApplied = false; } // x,y are canvas-relative (pre-scaled by _pixelDensity) @@ -1558,95 +706,25 @@ class RendererGL extends Renderer { this.GL.clear(this.GL.DEPTH_BUFFER_BIT); } - /** - * @private - * @returns {p5.Framebuffer} A p5.Framebuffer set to match the size and settings - * of the renderer's canvas. It will be created if it does not yet exist, and - * reused if it does. - */ - _getTempFramebuffer() { - if (!this._tempFramebuffer) { - this._tempFramebuffer = new Framebuffer(this, { - format: constants.UNSIGNED_BYTE, - useDepth: this._pInst._glAttributes.depth, - depthFormat: constants.UNSIGNED_INT, - antialias: this._pInst._glAttributes.antialias, - }); - } - return this._tempFramebuffer; - } - ////////////////////////////////////////////// - // HASH | for geometry - ////////////////////////////////////////////// - - geometryInHash(gid) { - return this.geometryBufferCache.isCached(gid); - } viewport(w, h) { this._viewport = [0, 0, w, h]; this.GL.viewport(0, 0, w, h); } - /** - * [resize description] - * @private - * @param {Number} w [description] - * @param {Number} h [description] - */ - resize(w, h) { - super.resize(w, h); - - // save canvas properties - const props = {}; - for (const key in this.drawingContext) { - const val = this.drawingContext[key]; - if (typeof val !== "object" && typeof val !== "function") { - props[key] = val; - } - } - - const dimensions = this._adjustDimensions(w, h); - w = dimensions.adjustedWidth; - h = dimensions.adjustedHeight; - - this.width = w; - this.height = h; - - this.canvas.width = w * this._pixelDensity; - this.canvas.height = h * this._pixelDensity; - this.canvas.style.width = `${w}px`; - this.canvas.style.height = `${h}px`; + _updateViewport() { this._origViewport = { width: this.GL.drawingBufferWidth, height: this.GL.drawingBufferHeight, }; this.viewport(this._origViewport.width, this._origViewport.height); + } - this.states.curCamera._resize(); - - //resize pixels buffer - if (typeof this.pixels !== "undefined") { - this.pixels = new Uint8Array( - this.GL.drawingBufferWidth * this.GL.drawingBufferHeight * 4 - ); - } - - for (const framebuffer of this.framebuffers) { - // Notify framebuffers of the resize so that any auto-sized framebuffers - // can also update their size - framebuffer._canvasSizeChanged(); - } - - // reset canvas properties - for (const savedKey in props) { - try { - this.drawingContext[savedKey] = props[savedKey]; - } catch (err) { - // ignore read-only property errors - } - } + _createPixelsArray() { + this.pixels = new Uint8Array( + this.GL.drawingBufferWidth * this.GL.drawingBufferHeight * 4 + ); } /** @@ -1693,107 +771,6 @@ class RendererGL extends Renderer { this.GL.clear(this.GL.DEPTH_BUFFER_BIT); } - applyMatrix(a, b, c, d, e, f) { - this.states.setValue("uModelMatrix", this.states.uModelMatrix.clone()); - if (arguments.length === 16) { - // this.states.uModelMatrix.apply(arguments); - Matrix.prototype.apply.apply(this.states.uModelMatrix, arguments); - } else { - this.states.uModelMatrix.apply([ - a, - b, - 0, - 0, - c, - d, - 0, - 0, - 0, - 0, - 1, - 0, - e, - f, - 0, - 1, - ]); - } - } - - /** - * [translate description] - * @private - * @param {Number} x [description] - * @param {Number} y [description] - * @param {Number} z [description] - * @chainable - * @todo implement handle for components or vector as args - */ - translate(x, y, z) { - if (x instanceof Vector) { - z = x.z; - y = x.y; - x = x.x; - } - this.states.setValue("uModelMatrix", this.states.uModelMatrix.clone()); - this.states.uModelMatrix.translate([x, y, z]); - return this; - } - - /** - * Scales the Model View Matrix by a vector - * @private - * @param {Number | p5.Vector | Array} x [description] - * @param {Number} [y] y-axis scalar - * @param {Number} [z] z-axis scalar - * @chainable - */ - scale(x, y, z) { - this.states.setValue("uModelMatrix", this.states.uModelMatrix.clone()); - this.states.uModelMatrix.scale(x, y, z); - return this; - } - - rotate(rad, axis) { - if (typeof axis === "undefined") { - return this.rotateZ(rad); - } - this.states.setValue("uModelMatrix", this.states.uModelMatrix.clone()); - Matrix.prototype.rotate4x4.apply(this.states.uModelMatrix, arguments); - return this; - } - - rotateX(rad) { - this.rotate(rad, 1, 0, 0); - return this; - } - - rotateY(rad) { - this.rotate(rad, 0, 1, 0); - return this; - } - - rotateZ(rad) { - this.rotate(rad, 0, 0, 1); - return this; - } - - pop(...args) { - if ( - this._clipDepths.length > 0 && - this._pushPopDepth === this._clipDepths[this._clipDepths.length - 1] - ) { - this._clearClip(); - if (!this._userEnabledStencil) { - this._internalDisable.call(this.GL, this.GL.STENCIL_TEST); - } - - // Reset saved state - // this._userEnabledStencil = this._savedStencilTestState; - } - super.pop(...args); - this._applyStencilTestIfClipping(); - } _applyStencilTestIfClipping() { const drawTarget = this.drawTarget(); if (drawTarget._isClipApplied !== this._stencilTestOn) { @@ -1808,13 +785,7 @@ class RendererGL extends Renderer { } } } - resetMatrix() { - this.states.setValue("uModelMatrix", this.states.uModelMatrix.clone()); - this.states.uModelMatrix.reset(); - this.states.setValue("uViewMatrix", this.states.uViewMatrix.clone()); - this.states.uViewMatrix.set(this.states.curCamera.cameraMatrix); - return this; - } + ////////////////////////////////////////////// // SHADER @@ -1826,15 +797,7 @@ class RendererGL extends Renderer { * and the shader must be valid in that context. */ - _getStrokeShader() { - // select the stroke shader to use - const stroke = this.states.userStrokeShader; - if (stroke) { - return stroke; - } - return this._getLineShader(); - } - + // TODO move to super class _getSphereMapping(img) { if (!this.sphereMapping) { this.sphereMapping = this._pInst.createFilterShader(sphereMapping); @@ -1848,53 +811,13 @@ class RendererGL extends Renderer { return this.sphereMapping; } - /* - * This method will handle both image shaders and - * fill shaders, returning the appropriate shader - * depending on the current context (image or shape). - */ - _getFillShader() { - // If drawing an image, check for user-defined image shader and filters - if (this._drawingImage) { - // Use user-defined image shader if available and no filter is applied - if (this.states.userImageShader && !this._drawingFilter) { - return this.states.userImageShader; - } else { - return this._getLightShader(); // Fallback to light shader - } - } - // If user has defined a fill shader, return that - else if (this.states.userFillShader) { - return this.states.userFillShader; - } - // Use normal shader if normal material is active - else if (this.states._useNormalMaterial) { - return this._getNormalShader(); - } - // Use light shader if lighting or textures are enabled - else if (this.states.enableLighting || this.states._tex) { - return this._getLightShader(); - } - // Default to color shader if no other conditions are met - return this._getColorShader(); - } - - _getPointShader() { - // select the point shader to use - const point = this.states.userPointShader; - if (!point || !point.isPointShader()) { - return this._getPointShader(); - } - return point; - } - baseMaterialShader() { if (!this._pInst._glAttributes.perPixelLighting) { throw new Error( "The material shader does not support hooks without perPixelLighting. Try turning it back on." ); } - return this._getLightShader(); + return super.baseMaterialShader(); } _getLightShader() { @@ -1945,10 +868,6 @@ class RendererGL extends Renderer { return this._defaultLightShader; } - baseNormalShader() { - return this._getNormalShader(); - } - _getNormalShader() { if (!this._defaultNormalShader) { this._defaultNormalShader = new Shader( @@ -1977,10 +896,6 @@ class RendererGL extends Renderer { return this._defaultNormalShader; } - baseColorShader() { - return this._getColorShader(); - } - _getColorShader() { if (!this._defaultColorShader) { this._defaultColorShader = new Shader( @@ -2009,34 +924,6 @@ class RendererGL extends Renderer { return this._defaultColorShader; } - /** - * TODO(dave): un-private this when there is a way to actually override the - * shader used for points - * - * Get the shader used when drawing points with `point()`. - * - * You can call `pointShader().modify()` - * and change any of the following hooks: - * - `void beforeVertex`: Called at the start of the vertex shader. - * - `vec3 getLocalPosition`: Update the position of vertices before transforms are applied. It takes in `vec3 position` and must return a modified version. - * - `vec3 getWorldPosition`: Update the position of vertices after transforms are applied. It takes in `vec3 position` and pust return a modified version. - * - `float getPointSize`: Update the size of the point. It takes in `float size` and must return a modified version. - * - `void afterVertex`: Called at the end of the vertex shader. - * - `void beforeFragment`: Called at the start of the fragment shader. - * - `bool shouldDiscard`: Points are drawn inside a square, with the corners discarded in the fragment shader to create a circle. Use this to change this logic. It takes in a `bool willDiscard` and must return a modified version. - * - `vec4 getFinalColor`: Update the final color after mixing. It takes in a `vec4 color` and must return a modified version. - * - `void afterFragment`: Called at the end of the fragment shader. - * - * Call `pointShader().inspectHooks()` to see all the possible hooks and - * their default implementations. - * - * @returns {p5.Shader} The `point()` shader - * @private() - */ - pointShader() { - return this._getPointShader(); - } - _getPointShader() { if (!this._defaultPointShader) { this._defaultPointShader = new Shader( @@ -2065,10 +952,6 @@ class RendererGL extends Renderer { return this._defaultPointShader; } - baseStrokeShader() { - return this._getLineShader(); - } - _getLineShader() { if (!this._defaultLineShader) { this._defaultLineShader = new Shader( @@ -2186,6 +1069,8 @@ class RendererGL extends Renderer { this.textures.set(src, tex); return tex; } + + // TODO move to super class /* * used in imageLight, * To create a blurry image from the input non blurry img, if it doesn't already exist @@ -2228,6 +1113,7 @@ class RendererGL extends Renderer { return newFramebuffer; } + // TODO move to super class /* * used in imageLight, * To create a texture from the input non blurry image, if it doesn't already exist @@ -2286,210 +1172,6 @@ class RendererGL extends Renderer { return tex; } - /** - * @private - * @returns {p5.Framebuffer|null} The currently active framebuffer, or null if - * the main canvas is the current draw target. - */ - activeFramebuffer() { - return this.activeFramebuffers[this.activeFramebuffers.length - 1] || null; - } - - createFramebuffer(options) { - return new Framebuffer(this, options); - } - - _setGlobalUniforms(shader) { - const modelMatrix = this.states.uModelMatrix; - const viewMatrix = this.states.uViewMatrix; - const projectionMatrix = this.states.uPMatrix; - const modelViewMatrix = modelMatrix.copy().mult(viewMatrix); - - shader.setUniform( - "uPerspective", - this.states.curCamera.useLinePerspective ? 1 : 0 - ); - shader.setUniform("uViewMatrix", viewMatrix.mat4); - shader.setUniform("uProjectionMatrix", projectionMatrix.mat4); - shader.setUniform("uModelMatrix", modelMatrix.mat4); - shader.setUniform("uModelViewMatrix", modelViewMatrix.mat4); - if (shader.uniforms.uModelViewProjectionMatrix) { - const modelViewProjectionMatrix = modelViewMatrix.copy(); - modelViewProjectionMatrix.mult(projectionMatrix); - shader.setUniform( - "uModelViewProjectionMatrix", - modelViewProjectionMatrix.mat4 - ); - } - if (shader.uniforms.uNormalMatrix) { - this.scratchMat3.inverseTranspose4x4(modelViewMatrix); - shader.setUniform("uNormalMatrix", this.scratchMat3.mat3); - } - if (shader.uniforms.uModelNormalMatrix) { - this.scratchMat3.inverseTranspose4x4(this.states.uModelMatrix); - shader.setUniform("uModelNormalMatrix", this.scratchMat3.mat3); - } - if (shader.uniforms.uCameraNormalMatrix) { - this.scratchMat3.inverseTranspose4x4(this.states.uViewMatrix); - shader.setUniform("uCameraNormalMatrix", this.scratchMat3.mat3); - } - if (shader.uniforms.uCameraRotation) { - this.scratchMat3.inverseTranspose4x4(this.states.uViewMatrix); - shader.setUniform("uCameraRotation", this.scratchMat3.mat3); - } - shader.setUniform("uViewport", this._viewport); - } - - _setStrokeUniforms(strokeShader) { - // set the uniform values - strokeShader.setUniform("uSimpleLines", this._simpleLines); - strokeShader.setUniform("uUseLineColor", this._useLineColor); - strokeShader.setUniform("uMaterialColor", this.states.curStrokeColor); - strokeShader.setUniform("uStrokeWeight", this.states.strokeWeight); - strokeShader.setUniform("uStrokeCap", STROKE_CAP_ENUM[this.curStrokeCap]); - strokeShader.setUniform( - "uStrokeJoin", - STROKE_JOIN_ENUM[this.curStrokeJoin] - ); - } - - _setFillUniforms(fillShader) { - this.mixedSpecularColor = [...this.states.curSpecularColor]; - const empty = this._getEmptyTexture(); - - if (this.states._useMetalness > 0) { - this.mixedSpecularColor = this.mixedSpecularColor.map( - (mixedSpecularColor, index) => - this.states.curFillColor[index] * this.states._useMetalness + - mixedSpecularColor * (1 - this.states._useMetalness) - ); - } - - // TODO: optimize - fillShader.setUniform("uUseVertexColor", this._useVertexColor); - fillShader.setUniform("uMaterialColor", this.states.curFillColor); - fillShader.setUniform("isTexture", !!this.states._tex); - // We need to explicitly set uSampler back to an empty texture here. - // In general, we record the last set texture so we can re-apply it - // the next time a shader is used. However, the texture() function - // works differently and is global p5 state. If the p5 state has - // been cleared, we also need to clear the value in uSampler to match. - fillShader.setUniform("uSampler", this.states._tex || empty); - fillShader.setUniform("uTint", this.states.tint); - - fillShader.setUniform("uHasSetAmbient", this.states._hasSetAmbient); - fillShader.setUniform("uAmbientMatColor", this.states.curAmbientColor); - fillShader.setUniform("uSpecularMatColor", this.mixedSpecularColor); - fillShader.setUniform("uEmissiveMatColor", this.states.curEmissiveColor); - fillShader.setUniform("uSpecular", this.states._useSpecularMaterial); - fillShader.setUniform("uEmissive", this.states._useEmissiveMaterial); - fillShader.setUniform("uShininess", this.states._useShininess); - fillShader.setUniform("uMetallic", this.states._useMetalness); - - this._setImageLightUniforms(fillShader); - - fillShader.setUniform("uUseLighting", this.states.enableLighting); - - const pointLightCount = this.states.pointLightDiffuseColors.length / 3; - fillShader.setUniform("uPointLightCount", pointLightCount); - fillShader.setUniform( - "uPointLightLocation", - this.states.pointLightPositions - ); - fillShader.setUniform( - "uPointLightDiffuseColors", - this.states.pointLightDiffuseColors - ); - fillShader.setUniform( - "uPointLightSpecularColors", - this.states.pointLightSpecularColors - ); - - const directionalLightCount = - this.states.directionalLightDiffuseColors.length / 3; - fillShader.setUniform("uDirectionalLightCount", directionalLightCount); - fillShader.setUniform( - "uLightingDirection", - this.states.directionalLightDirections - ); - fillShader.setUniform( - "uDirectionalDiffuseColors", - this.states.directionalLightDiffuseColors - ); - fillShader.setUniform( - "uDirectionalSpecularColors", - this.states.directionalLightSpecularColors - ); - - // TODO: sum these here... - const ambientLightCount = this.states.ambientLightColors.length / 3; - this.mixedAmbientLight = [...this.states.ambientLightColors]; - - if (this.states._useMetalness > 0) { - this.mixedAmbientLight = this.mixedAmbientLight.map((ambientColors) => { - let mixing = ambientColors - this.states._useMetalness; - return Math.max(0, mixing); - }); - } - fillShader.setUniform("uAmbientLightCount", ambientLightCount); - fillShader.setUniform("uAmbientColor", this.mixedAmbientLight); - - const spotLightCount = this.states.spotLightDiffuseColors.length / 3; - fillShader.setUniform("uSpotLightCount", spotLightCount); - fillShader.setUniform("uSpotLightAngle", this.states.spotLightAngle); - fillShader.setUniform("uSpotLightConc", this.states.spotLightConc); - fillShader.setUniform( - "uSpotLightDiffuseColors", - this.states.spotLightDiffuseColors - ); - fillShader.setUniform( - "uSpotLightSpecularColors", - this.states.spotLightSpecularColors - ); - fillShader.setUniform("uSpotLightLocation", this.states.spotLightPositions); - fillShader.setUniform( - "uSpotLightDirection", - this.states.spotLightDirections - ); - - fillShader.setUniform( - "uConstantAttenuation", - this.states.constantAttenuation - ); - fillShader.setUniform("uLinearAttenuation", this.states.linearAttenuation); - fillShader.setUniform( - "uQuadraticAttenuation", - this.states.quadraticAttenuation - ); - } - - // getting called from _setFillUniforms - _setImageLightUniforms(shader) { - //set uniform values - shader.setUniform("uUseImageLight", this.states.activeImageLight != null); - // true - if (this.states.activeImageLight) { - // this.states.activeImageLight has image as a key - // look up the texture from the diffusedTexture map - let diffusedLight = this.getDiffusedTexture(this.states.activeImageLight); - shader.setUniform("environmentMapDiffused", diffusedLight); - let specularLight = this.getSpecularTexture(this.states.activeImageLight); - - shader.setUniform("environmentMapSpecular", specularLight); - } - } - - _setPointUniforms(pointShader) { - // set the uniform values - pointShader.setUniform("uMaterialColor", this.states.curStrokeColor); - // @todo is there an instance where this isn't stroke weight? - // should be they be same var? - pointShader.setUniform( - "uPointSize", - this.states.strokeWeight * this._pixelDensity - ); - } - /* Binds a buffer to the drawing context * when passed more than two arguments it also updates or initializes * the data associated with the buffer @@ -2508,6 +1190,14 @@ class RendererGL extends Renderer { } } + _makeFilterShader(renderer, operation) { + return new Shader( + renderer, + filterShaderVert, + filterShaderFrags[operation] + ); + } + /////////////////////////////// //// UTILITY FUNCTIONS ////////////////////////////// @@ -2614,7 +1304,7 @@ function rendererGL(p5, fn) { * } * * - * + * *
* * // Now with the antialias attribute set to true. diff --git a/src/webgpu/p5.RendererWebGPU.js b/src/webgpu/p5.RendererWebGPU.js new file mode 100644 index 0000000000..268e597af3 --- /dev/null +++ b/src/webgpu/p5.RendererWebGPU.js @@ -0,0 +1,27 @@ +import { Renderer3D } from '../core/p5.Renderer3D'; + +class RendererWebGPU extends Renderer3D { + constructor(pInst, w, h, isMainCanvas, elt) { + super(pInst, w, h, isMainCanvas, elt) + } + + setupContext() { + // TODO + } + + _resetBuffersBeforeDraw() { + // TODO + } + + ////////////////////////////////////////////// + // Setting + ////////////////////////////////////////////// + _adjustDimensions(width, height) { + // TODO: find max texture size + return { adjustedWidth: width, adjustedHeight: height }; + } + + _applyStencilTestIfClipping() { + // TODO + } +} From 3af1624fb8a7c6dabec6e69e0da9d55036a233a7 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Fri, 23 May 2025 19:01:27 -0400 Subject: [PATCH 02/72] Get background() working --- preview/index.html | 22 ++--- src/core/constants.js | 7 ++ src/core/p5.Renderer3D.js | 55 ++++++------ src/core/rendering.js | 6 +- src/webgl/3d_primitives.js | 44 ++++----- src/webgl/GeometryBufferCache.js | 61 +++---------- src/webgl/light.js | 20 ++--- src/webgl/material.js | 30 +++---- src/webgl/p5.Camera.js | 18 ++-- src/webgl/p5.Framebuffer.js | 2 +- src/webgl/p5.RendererGL.js | 148 ++++++++++--------------------- src/webgl/text.js | 8 +- src/webgl/utils.js | 99 +++++++++++++++++++++ src/webgpu/p5.RendererWebGPU.js | 125 +++++++++++++++++++++++++- 14 files changed, 390 insertions(+), 255 deletions(-) create mode 100644 src/webgl/utils.js diff --git a/preview/index.html b/preview/index.html index d0f3b329ae..2cc9391628 100644 --- a/preview/index.html +++ b/preview/index.html @@ -18,29 +18,21 @@ - \ No newline at end of file + diff --git a/src/core/p5.Renderer3D.js b/src/core/p5.Renderer3D.js index fb26a639c8..a2e40d83c5 100644 --- a/src/core/p5.Renderer3D.js +++ b/src/core/p5.Renderer3D.js @@ -557,13 +557,12 @@ export class Renderer3D extends Renderer { geometry.hasFillTransparency() ); - this._drawBuffers(geometry, { mode, count }); + this._drawBuffers(geometry, { mode, count }, false); shader.unbindShader(); } _drawStrokes(geometry, { count } = {}) { - const gl = this.GL; this._useLineColor = geometry.vertexStrokeColors.length > 0; @@ -584,22 +583,7 @@ export class Renderer3D extends Renderer { geometry.hasStrokeTransparency() ); - if (count === 1) { - gl.drawArrays(gl.TRIANGLES, 0, geometry.lineVertices.length / 3); - } else { - try { - gl.drawArraysInstanced( - gl.TRIANGLES, - 0, - geometry.lineVertices.length / 3, - count - ); - } catch (e) { - console.log( - "🌸 p5.js says: Instancing is only supported in WebGL2 mode" - ); - } - } + this._drawBuffers(geometry, {count}, true) shader.unbindShader(); } @@ -1430,7 +1414,7 @@ export class Renderer3D extends Renderer { this.scratchMat3.inverseTranspose4x4(this.states.uViewMatrix); shader.setUniform("uCameraRotation", this.scratchMat3.mat3); } - shader.setUniform("uViewport", this._viewport); + shader.setUniform("uViewport", [0, 0, 400, 400]); } _setStrokeUniforms(strokeShader) { diff --git a/src/webgl/p5.RendererGL.js b/src/webgl/p5.RendererGL.js index 7ef7e61bf7..e033e3b1c2 100644 --- a/src/webgl/p5.RendererGL.js +++ b/src/webgl/p5.RendererGL.js @@ -270,9 +270,33 @@ class RendererGL extends Renderer3D { } } + // Stroke version for now: + // +// { +// const gl = this.GL; +// // move this to _drawBuffers ? +// if (count === 1) { +// gl.drawArrays(gl.TRIANGLES, 0, geometry.lineVertices.length / 3); +// } else { +// try { + // gl.drawArraysInstanced( + // gl.TRIANGLES, + // 0, + // geometry.lineVertices.length / 3, + // count + // ); + // } catch (e) { + // console.log( + // "🌸 p5.js says: Instancing is only supported in WebGL2 mode" + // ); + // } + // } + // } + _drawBuffers(geometry, { mode = constants.TRIANGLES, count }) { const gl = this.GL; const glBuffers = this.geometryBufferCache.getCached(geometry); + //console.log(glBuffers); if (!glBuffers) return; @@ -1157,7 +1181,7 @@ class RendererGL extends Renderer3D { if (indices) { const buffer = gl.createBuffer(); - this.renderer._bindBuffer(buffer, gl.ELEMENT_ARRAY_BUFFER, indices, indexType); + this._bindBuffer(buffer, gl.ELEMENT_ARRAY_BUFFER, indices, indexType); buffers.indexBuffer = buffer; @@ -1478,9 +1502,9 @@ class RendererGL extends Renderer3D { createTexture({ width, height, format, dataType }) { const gl = this.GL; const tex = gl.createTexture(); - this.gl.bindTexture(gl.TEXTURE_2D, tex); - this.gl.texImage2D(gl.TEXTURE_2D, 0, this.gl.RGBA, width, height, 0, - gl.RGBA, this.gl.UNSIGNED_BYTE, null); + gl.bindTexture(gl.TEXTURE_2D, tex); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, + gl.RGBA, gl.UNSIGNED_BYTE, null); // TODO use format and data type return { texture: tex, glFormat: gl.RGBA, glDataType: gl.UNSIGNED_BYTE }; } diff --git a/src/webgl/shaders/line.vert b/src/webgl/shaders/line.vert index de422ad6b6..a00bf94ba8 100644 --- a/src/webgl/shaders/line.vert +++ b/src/webgl/shaders/line.vert @@ -271,6 +271,7 @@ void main() { } } else { vec2 tangent = aTangentIn == vec3(0.) ? tangentOut : tangentIn; + vTangent = tangent; vec2 normal = vec2(-tangent.y, tangent.x); diff --git a/src/webgpu/p5.RendererWebGPU.js b/src/webgpu/p5.RendererWebGPU.js index a58720d66f..ffb012e1f1 100644 --- a/src/webgpu/p5.RendererWebGPU.js +++ b/src/webgpu/p5.RendererWebGPU.js @@ -2,6 +2,7 @@ import { Renderer3D } from '../core/p5.Renderer3D'; import { Shader } from '../webgl/p5.Shader'; import * as constants from '../core/constants'; import { colorVertexShader, colorFragmentShader } from './shaders/color'; +import { lineVertexShader, lineFragmentShader} from './shaders/line'; class RendererWebGPU extends Renderer3D { constructor(pInst, w, h, isMainCanvas, elt) { @@ -248,7 +249,6 @@ class RendererWebGPU extends Renderer3D { .filter(u => !u.isSampler) .reduce((sum, u) => sum + u.alignedBytes, 0); shader._uniformData = new Float32Array(uniformSize / 4); - shader._uniformBuffer = this.device.createBuffer({ size: uniformSize, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, @@ -453,7 +453,6 @@ class RendererWebGPU extends Renderer3D { for (const attrName in shader.attributes) { const attr = shader.attributes[attrName]; if (!attr || attr.location === -1) continue; - // Get the vertex buffer info associated with this attribute const renderBuffer = this.buffers[shader.shaderType].find(buf => buf.attr === attrName) || @@ -477,7 +476,6 @@ class RendererWebGPU extends Renderer3D { ], }); } - return layouts; } @@ -512,7 +510,9 @@ class RendererWebGPU extends Renderer3D { _useShader(shader, options) {} - _updateViewport() {} + _updateViewport() { + this._viewport = [0, 0, this.width, this.height]; + } zClipRange() { return [0, 1]; @@ -548,13 +548,14 @@ class RendererWebGPU extends Renderer3D { // Rendering ////////////////////////////////////////////// - _drawBuffers(geometry, { mode = constants.TRIANGLES, count = 1 }) { + _drawBuffers(geometry, { mode = constants.TRIANGLES, count = 1 }, stroke) { const buffers = this.geometryBufferCache.getCached(geometry); if (!buffers) return; const commandEncoder = this.device.createCommandEncoder(); + const currentTexture = this.drawingContext.getCurrentTexture(); const colorAttachment = { - view: this.drawingContext.getCurrentTexture().createView(), + view: currentTexture.createView(), loadOp: "load", storeOp: "store", }; @@ -578,7 +579,6 @@ class RendererWebGPU extends Renderer3D { const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor); passEncoder.setPipeline(this._curShader.getPipeline(this._shaderOptions({ mode }))); - // Bind vertex buffers for (const buffer of this._getVertexBuffers(this._curShader)) { passEncoder.setVertexBuffer( @@ -587,9 +587,9 @@ class RendererWebGPU extends Renderer3D { 0 ); } - // Bind uniforms this._packUniforms(this._curShader); + console.log(this._curShader); this.device.queue.writeBuffer( this._curShader._uniformBuffer, 0, @@ -621,18 +621,21 @@ class RendererWebGPU extends Renderer3D { layout, entries: bgEntries, }); - passEncoder.setBindGroup(group, bindGroup); } + if (buffers.lineVerticesBuffer && geometry.lineVertices && stroke) { + passEncoder.draw(geometry.lineVertices.length / 3, count, 0, 0); + } // Bind index buffer and issue draw + if (!stroke) { if (buffers.indexBuffer) { const indexFormat = buffers.indexFormat || "uint16"; passEncoder.setIndexBuffer(buffers.indexBuffer, indexFormat); passEncoder.drawIndexed(geometry.faces.length * 3, count, 0, 0, 0); } else { passEncoder.draw(geometry.vertices.length, count, 0, 0); - } + }} passEncoder.end(); this.queue.submit([commandEncoder.finish()]); @@ -644,6 +647,7 @@ class RendererWebGPU extends Renderer3D { _packUniforms(shader) { let offset = 0; + let i = 0; for (const name in shader.uniforms) { const uniform = shader.uniforms[name]; if (uniform.isSampler) continue; @@ -661,7 +665,7 @@ class RendererWebGPU extends Renderer3D { new RegExp(`struct\\s+${structName}\\s*\\{([^\\}]+)\\}`) ); if (!structMatch) { - throw new Error(`Can't find a struct definition for ${structName}`); + throw new Error(`Can't find a struct defnition for ${structName}`); } const structBody = structMatch[1]; @@ -716,7 +720,6 @@ class RendererWebGPU extends Renderer3D { } const structType = uniformVarMatch[2]; const uniforms = this._parseStruct(shader.vertSrc(), structType); - // Extract samplers from group bindings const samplers = []; const samplerRegex = /@group\((\d+)\)\s*@binding\((\d+)\)\s*var\s+(\w+)\s*:\s*(\w+);/g; @@ -835,6 +838,28 @@ class RendererWebGPU extends Renderer3D { return this._defaultColorShader; } + _getLineShader() { + if (!this._defaultLineShader) { + this._defaultLineShader = new Shader( + this, + lineVertexShader, + lineFragmentShader, + { + vertex: { + "void beforeVertex": "() {}", + "Vertex getObjectInputs": "(inputs: Vertex) { return inputs; }", + "Vertex getWorldInputs": "(inputs: Vertex) { return inputs; }", + "Vertex getCameraInputs": "(inputs: Vertex) { return inputs; }", + }, + fragment: { + "vec4 getFinalColor": "(color: vec4) { return color; }" + }, + } + ); + } + return this._defaultLineShader; + } + ////////////////////////////////////////////// // Setting ////////////////////////////////////////////// @@ -921,7 +946,7 @@ class RendererWebGPU extends Renderer3D { } } - console.log(preMain + '\n' + defines + hooks + main + postMain) + //console.log(preMain + '\n' + defines + hooks + main + postMain) return preMain + '\n' + defines + hooks + main + postMain; } } diff --git a/src/webgpu/shaders/line.js b/src/webgpu/shaders/line.js new file mode 100644 index 0000000000..a42e7b178e --- /dev/null +++ b/src/webgpu/shaders/line.js @@ -0,0 +1,317 @@ +import { getTexture } from './utils' + +const uniforms = ` +struct Uniforms { +// @p5 ifdef Vertex getWorldInputs + uModelMatrix: mat4x4, + uViewMatrix: mat4x4, +// @p5 endif +// @p5 ifndef Vertex getWorldInputs + uModelViewMatrix: mat4x4, +// @p5 endif + uMaterialColor: vec4, + uProjectionMatrix: mat4x4, + uStrokeWeight: f32, + uUseLineColor: f32, + uSimpleLines: f32, + uViewport: vec4, + uPerspective: i32, + uStrokeJoin: i32, +} +`; + +export const lineVertexShader = ` +struct StrokeVertexInput { + @location(0) aPosition: vec3, + @location(1) aSide: f32, + @location(2) aTangentIn: vec3, + @location(3) aTangentOut: vec3, + @location(4) aVertexColor: vec4, +}; + +struct StrokeVertexOutput { + @builtin(position) Position: vec4, + @location(0) vColor: vec4, + @location(1) vTangent: vec2, + @location(2) vCenter: vec2, + @location(3) vPosition: vec2, + @location(4) vMaxDist: f32, + @location(5) vCap: f32, + @location(6) vJoin: f32, + @location(7) vStrokeWeight: f32, +}; + +${uniforms} +@group(0) @binding(0) var uniforms: Uniforms; + +struct Vertex { + position: vec3, + tangentIn: vec3, + tangentOut: vec3, + color: vec4, + weight: f32, +} + +fn lineIntersection(aPoint: vec2f, aDir: vec2f, bPoint: vec2f, bDir: vec2f) -> vec2f { + // Rotate and translate so a starts at the origin and goes out to the right + var bMutPoint = bPoint; + bMutPoint -= aPoint; + var rotatedBFrom = vec2( + bMutPoint.x*aDir.x + bMutPoint.y*aDir.y, + bMutPoint.y*aDir.x - bMutPoint.x*aDir.y + ); + var bTo = bMutPoint + bDir; + var rotatedBTo = vec2( + bTo.x*aDir.x + bTo.y*aDir.y, + bTo.y*aDir.x - bTo.x*aDir.y + ); + var intersectionDistance = + rotatedBTo.x + (rotatedBFrom.x - rotatedBTo.x) * rotatedBTo.y / + (rotatedBTo.y - rotatedBFrom.y); + return aPoint + aDir * intersectionDistance; +} + +@vertex +fn main(input: StrokeVertexInput) -> StrokeVertexOutput { + HOOK_beforeVertex(); + var output: StrokeVertexOutput; + let viewport = vec4(0.,0.,400.,400.); + let simpleLines = (uniforms.uSimpleLines != 0.); + if (!simpleLines) { + if (all(input.aTangentIn == vec3()) != all(input.aTangentOut == vec3())) { + output.vCap = 1.; + } else { + output.vCap = 0.; + } + let conditionA = any(input.aTangentIn != vec3()); + let conditionB = any(input.aTangentOut != vec3()); + let conditionC = any(input.aTangentIn != input.aTangentOut); + if (conditionA && conditionB && conditionC) { + output.vJoin = 1.; + } else { + output.vJoin = 0.; + } + } + var lineColor: vec4; + if (uniforms.uUseLineColor != 0.) { + lineColor = input.aVertexColor; + } else { + lineColor = uniforms.uMaterialColor; + } + var inputs = Vertex( + input.aPosition.xyz, + input.aTangentIn, + input.aTangentOut, + lineColor, + uniforms.uStrokeWeight + ); + +// @p5 ifdef Vertex getObjectInputs + inputs = HOOK_getObjectInputs(inputs); +// @p5 endif + +// @p5 ifdef Vertex getWorldInputs + inputs.position = (uModelMatrix * vec4(inputs.position, 1.)).xyz; + inputs.tangentIn = (uModelMatrix * vec4(input.aTangentIn, 1.)).xyz; + inputs.tangentOut = (uModelMatrix * vec4(input.aTangentOut, 1.)).xyz; +// @p5 endif + +// @p5 ifdef Vertex getWorldInputs + // Already multiplied by the model matrix, just apply view + inputs.position = (uniforms.uViewMatrix * vec4(inputs.position, 1.)).xyz; + inputs.tangentIn = (uniforms.uViewMatrix * vec4(input.aTangentIn, 0.)).xyz; + inputs.tangentOut = (uniforms.uViewMatrix * vec4(input.aTangentOut, 0.)).xyz; +// @p5 endif +// @p5 ifndef Vertex getWorldInputs + // Apply both at once + inputs.position = (uniforms.uModelViewMatrix * vec4(inputs.position, 1.)).xyz; + inputs.tangentIn = (uniforms.uModelViewMatrix * vec4(input.aTangentIn, 0.)).xyz; + inputs.tangentOut = (uniforms.uModelViewMatrix * vec4(input.aTangentOut, 0.)).xyz; +// @p5 endif +// @p5 ifdef Vertex getCameraInputs + inputs = HOOK_getCameraInputs(inputs); +// @p5 endif + + var posp = vec4(inputs.position, 1.); + var posqIn = vec4(inputs.position + inputs.tangentIn, 1.); + var posqOut = vec4(inputs.position + inputs.tangentOut, 1.); + output.vStrokeWeight = inputs.weight; + + var facingCamera = pow( + // The word space tangent's z value is 0 if it's facing the camera + abs(normalize(posqIn-posp).z), + + // Using pow() here to ramp 'facingCamera' up from 0 to 1 really quickly + // so most lines get scaled and don't get clipped + 0.25 + ); + + // Moving vertices slightly toward the camera + // to avoid depth-fighting with the fill triangles. + // A mix of scaling and offsetting is used based on distance + // Discussion here: + // https://github.com/processing/p5.js/issues/7200 + + // using a scale <1 moves the lines towards nearby camera + // in order to prevent popping effects due to half of + // the line disappearing behind the geometry faces. + var zDistance = -posp.z; + var distanceFactor = smoothstep(0., 800., zDistance); + + // Discussed here: + // http://www.opengl.org/discussion_boards/ubbthreads.php?ubb=showflat&Number=252848 + var scale = mix(1., 0.995, facingCamera); + var dynamicScale = mix(scale, 1.0, distanceFactor); // Closer = more scale, farther = less + + posp = vec4(posp.xyz * dynamicScale, posp.w); + posqIn = vec4(posqIn.xyz * dynamicScale, posqIn.w); + posqOut= vec4(posqOut.xyz * dynamicScale, posqOut.w); + + // Moving vertices slightly toward camera when far away + // https://github.com/processing/p5.js/issues/6956 + var zOffset = mix(0., -1., facingCamera); + var dynamicZAdjustment = mix(0., zOffset, distanceFactor); // Closer = less zAdjustment, farther = more + + posp.z -= dynamicZAdjustment; + posqIn.z -= dynamicZAdjustment; + posqOut.z -= dynamicZAdjustment; + + var p = uniforms.uProjectionMatrix * posp; + var qIn = uniforms.uProjectionMatrix * posqIn; + var qOut = uniforms.uProjectionMatrix * posqOut; + + var tangentIn = normalize((qIn.xy * p.w - p.xy * qIn.w) * viewport.zw); + var tangentOut = normalize((qOut.xy * p.w - p.xy * qOut.w) * viewport.zw); + + var curPerspScale = vec2(); + if (uniforms.uPerspective == 1) { + // Perspective --- + // convert from world to clip by multiplying with projection scaling factor + // to get the right thickness (see https://github.com/processing/processing/issues/5182) + + // The y value of the projection matrix may be flipped if rendering to a Framebuffer. + // Multiplying again by its sign here negates the flip to get just the scale. + curPerspScale = (uniforms.uProjectionMatrix * vec4(1., sign(uniforms.uProjectionMatrix[1][1]), 0., 0.)).xy; + } else { + // No Perspective --- + // multiply by W (to cancel out division by W later in the pipeline) and + // convert from screen to clip (derived from clip to screen above) + curPerspScale = p.w / (0.5 * viewport.zw); + } + + var offset = vec2(); + if (output.vJoin == 1. && !simpleLines) { + output.vTangent = normalize(tangentIn + tangentOut); + var normalIn = vec2(-tangentIn.y, tangentIn.x); + var normalOut = vec2(-tangentOut.y, tangentOut.x); + var side = sign(input.aSide); + var sideEnum = abs(input.aSide); + + // We generate vertices for joins on either side of the centerline, but + // the "elbow" side is the only one needing a join. By not setting the + // offset for the other side, all its vertices will end up in the same + // spot and not render, effectively discarding it. + if (sign(dot(tangentOut, vec2(-tangentIn.y, tangentIn.x))) != side) { + // Side enums: + // 1: the side going into the join + // 2: the middle of the join + // 3: the side going out of the join + if (sideEnum == 2.) { + // Calculate the position + tangent on either side of the join, and + // find where the lines intersect to find the elbow of the join + var c = (posp.xy / posp.w + vec2(1.)) * 0.5 * viewport.zw; + + var intersection = lineIntersection( + c + (side * normalIn * inputs.weight / 2.), + tangentIn, + c + (side * normalOut * inputs.weight / 2.), + tangentOut + ); + offset = intersection - c; + + + // When lines are thick and the angle of the join approaches 180, the + // elbow might be really far from the center. We'll apply a limit to + // the magnitude to avoid lines going across the whole screen when this + // happens. + var mag = length(offset); + var maxMag = 3 * inputs.weight; + if (mag > maxMag) { + offset = vec2(maxMag / mag); + } else if (sideEnum == 1.) { + offset = side * normalIn * inputs.weight / 2.; + } else if (sideEnum == 3.) { + offset = side * normalOut * inputs.weight / 2.; + } + } + } + if (uniforms.uStrokeJoin == 2) { + var avgNormal = vec2(-output.vTangent.y, output.vTangent.x); + output.vMaxDist = abs(dot(avgNormal, normalIn * inputs.weight / 2.)); + } else { + output.vMaxDist = inputs.weight / 2.; + } + } else { + var tangent: vec2; + if (all(input.aTangentIn == vec3())) { + tangent = tangentOut; + } else { + tangent = tangentIn; + } + output.vTangent = tangent; + var normal = vec2(-tangent.y, tangent.y); + + var normalOffset = sign(input.aSide); + // Caps will have side values of -2 or 2 on the edge of the cap that + // extends out from the line + var tangentOffset = abs(input.aSide) - 1.; + offset = (normal * normalOffset + tangent * tangentOffset) * + inputs.weight * 0.5; + output.vMaxDist = inputs.weight / 2.; + } + output.vCenter = p.xy; + output.vPosition = output.vCenter + offset; + output.vColor = inputs.color; + + output.Position = vec4( + p.xy + offset.xy * curPerspScale, + p.zy + ); + var clip_pos: vec4; + if (input.aSide == 1.0) { + clip_pos = vec4(-0.1, 0.1, 0.5, 1.); + } else if (input.aSide == -1.0) { + clip_pos = vec4(-0.5, 0.5, 0.5, 1.0); + } else { + clip_pos = vec4(0.0, -0.5, 0.5 ,1.0); + } + output.Position = clip_pos; + return output; +} + + +`; + +export const lineFragmentShader = ` +struct StrokeFragmentInput { + @location(0) vColor: vec4, + @location(1) vTangent: vec2, + @location(2) vCenter: vec2, + @location(3) vPosition: vec2, + @location(4) vMaxDist: f32, + @location(5) vCap: f32, + @location(6) vJoin: f32, + @location(7) vStrokeWeight: f32, +} + +${uniforms} +@group(0) @binding(0) var uniforms: Uniforms; + +${getTexture} + +@fragment +fn main(input: StrokeFragmentInput) -> @location(0) vec4 { + return vec4(1., 1., 1., 1.); +} +`; + From ae2c56685161418ecdfdc95478aef66cf4bd5efd Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Sun, 15 Jun 2025 16:40:17 -0400 Subject: [PATCH 11/72] Add material shader --- preview/index.html | 7 +- src/core/p5.Renderer3D.js | 23 ++- src/webgl/light.js | 16 +- src/webgl/p5.Shader.js | 7 +- src/webgl/shaders/basic.frag | 3 +- src/webgl/shaders/lighting.glsl | 2 - src/webgl/shaders/phong.frag | 5 +- src/webgl/shaders/phong.vert | 11 - src/webgpu/p5.RendererWebGPU.js | 160 ++++++++++++--- src/webgpu/shaders/color.js | 9 +- src/webgpu/shaders/material.js | 348 ++++++++++++++++++++++++++++++++ 11 files changed, 528 insertions(+), 63 deletions(-) create mode 100644 src/webgpu/shaders/material.js diff --git a/preview/index.html b/preview/index.html index 01a65cd11d..26a9c7196b 100644 --- a/preview/index.html +++ b/preview/index.html @@ -27,7 +27,7 @@ let sh; p.setup = async function () { await p.createCanvas(400, 400, p.WEBGPU); - sh = p.baseColorShader().modify({ + sh = p.baseMaterialShader().modify({ uniforms: { 'f32 time': () => p.millis(), }, @@ -44,6 +44,11 @@ p.background(200); p.noStroke(); p.shader(sh); + p.ambientLight(50); + p.directionalLight(100, 100, 100, 0, 1, -1); + p.pointLight(155, 155, 155, 0, -200, 500); + p.specularMaterial(255); + p.shininess(300); for (const [i, c] of ['red', 'lime', 'blue'].entries()) { p.push(); p.fill(c); diff --git a/src/core/p5.Renderer3D.js b/src/core/p5.Renderer3D.js index fb26a639c8..c8ed32ea8a 100644 --- a/src/core/p5.Renderer3D.js +++ b/src/core/p5.Renderer3D.js @@ -1515,17 +1515,20 @@ export class Renderer3D extends Renderer { ); // TODO: sum these here... - const ambientLightCount = this.states.ambientLightColors.length / 3; - this.mixedAmbientLight = [...this.states.ambientLightColors]; - - if (this.states._useMetalness > 0) { - this.mixedAmbientLight = this.mixedAmbientLight.map((ambientColors) => { - let mixing = ambientColors - this.states._useMetalness; - return Math.max(0, mixing); - }); + let mixedAmbientLight = [0, 0, 0]; + for (let i = 0; i < this.states.ambientLightColors.length; i += 3) { + for (let off = 0; off < 3; off++) { + if (this.states._useMetalness > 0) { + mixedAmbientLight[off] += Math.max( + 0, + this.states.ambientLightColors[i + off] - this.states._useMetalness + ); + } else { + mixedAmbientLight[off] += this.states.ambientLightColors[i + off]; + } + } } - fillShader.setUniform("uAmbientLightCount", ambientLightCount); - fillShader.setUniform("uAmbientColor", this.mixedAmbientLight); + fillShader.setUniform("uAmbientColor", mixedAmbientLight); const spotLightCount = this.states.spotLightDiffuseColors.length / 3; fillShader.setUniform("uSpotLightCount", spotLightCount); diff --git a/src/webgl/light.js b/src/webgl/light.js index 1c714ae02a..e57dd09686 100644 --- a/src/webgl/light.js +++ b/src/webgl/light.js @@ -1620,6 +1620,8 @@ function light(p5, fn){ angle, concentration ) { + if (this.states.spotLightDiffuseColors.length / 3 >= 4) return; + let color, position, direction; const length = arguments.length; @@ -1777,18 +1779,26 @@ function light(p5, fn){ return; } this.states.setValue('spotLightDiffuseColors', [ + ...this.states.spotLightDiffuseColors, color._array[0], color._array[1], color._array[2] ]); this.states.setValue('spotLightSpecularColors', [ + ...this.states.spotLightSpecularColors, ...this.states.specularColors ]); - this.states.setValue('spotLightPositions', [position.x, position.y, position.z]); + this.states.setValue('spotLightPositions', [ + ...this.states.spotLightPositions, + position.x, + position.y, + position.z + ]); direction.normalize(); this.states.setValue('spotLightDirections', [ + ...this.states.spotLightDirections, direction.x, direction.y, direction.z @@ -1808,8 +1818,8 @@ function light(p5, fn){ } angle = this._pInst._toRadians(angle); - this.states.setValue('spotLightAngle', [Math.cos(angle)]); - this.states.setValue('spotLightConc', [concentration]); + this.states.setValue('spotLightAngle', [...this.states.spotLightAngle, Math.cos(angle)]); + this.states.setValue('spotLightConc', [...this.states.spotLightConc, concentration]); this.states.setValue('enableLighting', true); } diff --git a/src/webgl/p5.Shader.js b/src/webgl/p5.Shader.js index 3f4015311c..fc3745e394 100644 --- a/src/webgl/p5.Shader.js +++ b/src/webgl/p5.Shader.js @@ -976,14 +976,17 @@ class Shader { * *
*/ - setUniform(uniformName, data) { + setUniform(uniformName, rawData) { this.init(); const uniform = this.uniforms[uniformName]; if (!uniform) { return; } - const gl = this._renderer.GL; + + const data = this._renderer._mapUniformData + ? this._renderer._mapUniformData(uniform, rawData) + : rawData; if (uniform.isArray) { if ( diff --git a/src/webgl/shaders/basic.frag b/src/webgl/shaders/basic.frag index e583955d36..1406964ca9 100644 --- a/src/webgl/shaders/basic.frag +++ b/src/webgl/shaders/basic.frag @@ -1,6 +1,7 @@ IN vec4 vColor; void main(void) { HOOK_beforeFragment(); - OUT_COLOR = HOOK_getFinalColor(vec4(vColor.rgb, 1.) * vColor.a); + OUT_COLOR = HOOK_getFinalColor(vColor); + OUT_COLOR.rgb *= OUT_COLOR.a; // Premultiply alpha before rendering HOOK_afterFragment(); } diff --git a/src/webgl/shaders/lighting.glsl b/src/webgl/shaders/lighting.glsl index b66ac083d1..85a4c79684 100644 --- a/src/webgl/shaders/lighting.glsl +++ b/src/webgl/shaders/lighting.glsl @@ -7,8 +7,6 @@ uniform mat4 uViewMatrix; uniform bool uUseLighting; -uniform int uAmbientLightCount; -uniform vec3 uAmbientColor[5]; uniform mat3 uCameraRotation; uniform int uDirectionalLightCount; uniform vec3 uLightingDirection[5]; diff --git a/src/webgl/shaders/phong.frag b/src/webgl/shaders/phong.frag index a424c6220c..78cfb76163 100644 --- a/src/webgl/shaders/phong.frag +++ b/src/webgl/shaders/phong.frag @@ -2,6 +2,7 @@ precision highp int; uniform bool uHasSetAmbient; +uniform vec3 uAmbientColor; uniform vec4 uSpecularMatColor; uniform vec4 uAmbientMatColor; uniform vec4 uEmissiveMatColor; @@ -13,7 +14,6 @@ uniform bool isTexture; IN vec3 vNormal; IN vec2 vTexCoord; IN vec3 vViewPosition; -IN vec3 vAmbientColor; IN vec4 vColor; struct ColorComponents { @@ -45,7 +45,7 @@ void main(void) { Inputs inputs; inputs.normal = normalize(vNormal); inputs.texCoord = vTexCoord; - inputs.ambientLight = vAmbientColor; + inputs.ambientLight = uAmbientColor; inputs.color = isTexture ? TEXTURE(uSampler, vTexCoord) * (vec4(uTint.rgb/255., 1.) * uTint.a/255.) : vColor; @@ -67,7 +67,6 @@ void main(void) { // Calculating final color as result of all lights (plus emissive term). - vec2 texCoord = inputs.texCoord; vec4 baseColor = inputs.color; ColorComponents c; c.opacity = baseColor.a; diff --git a/src/webgl/shaders/phong.vert b/src/webgl/shaders/phong.vert index 670da028c1..49a10933fc 100644 --- a/src/webgl/shaders/phong.vert +++ b/src/webgl/shaders/phong.vert @@ -7,8 +7,6 @@ IN vec3 aNormal; IN vec2 aTexCoord; IN vec4 aVertexColor; -uniform vec3 uAmbientColor[5]; - #ifdef AUGMENTED_HOOK_getWorldInputs uniform mat4 uModelMatrix; uniform mat4 uViewMatrix; @@ -19,7 +17,6 @@ uniform mat4 uModelViewMatrix; uniform mat3 uNormalMatrix; #endif uniform mat4 uProjectionMatrix; -uniform int uAmbientLightCount; uniform bool uUseVertexColor; uniform vec4 uMaterialColor; @@ -74,14 +71,6 @@ void main(void) { vNormal = inputs.normal; vColor = inputs.color; - // TODO: this should be a uniform - vAmbientColor = vec3(0.0); - for (int i = 0; i < 5; i++) { - if (i < uAmbientLightCount) { - vAmbientColor += uAmbientColor[i]; - } - } - gl_Position = uProjectionMatrix * vec4(inputs.position, 1.); HOOK_afterVertex(); } diff --git a/src/webgpu/p5.RendererWebGPU.js b/src/webgpu/p5.RendererWebGPU.js index a58720d66f..a2bcaca885 100644 --- a/src/webgpu/p5.RendererWebGPU.js +++ b/src/webgpu/p5.RendererWebGPU.js @@ -2,6 +2,7 @@ import { Renderer3D } from '../core/p5.Renderer3D'; import { Shader } from '../webgl/p5.Shader'; import * as constants from '../core/constants'; import { colorVertexShader, colorFragmentShader } from './shaders/color'; +import { materialVertexShader, materialFragmentShader } from './shaders/material'; class RendererWebGPU extends Renderer3D { constructor(pInst, w, h, isMainCanvas, elt) { @@ -244,13 +245,16 @@ class RendererWebGPU extends Renderer3D { } _finalizeShader(shader) { - const uniformSize = Object.values(shader.uniforms) - .filter(u => !u.isSampler) - .reduce((sum, u) => sum + u.alignedBytes, 0); - shader._uniformData = new Float32Array(uniformSize / 4); + const rawSize = Math.max( + 0, + ...Object.values(shader.uniforms).map(u => u.offsetEnd) + ); + const alignedSize = Math.ceil(rawSize / 16) * 16; + shader._uniformData = new Float32Array(alignedSize / 4); + shader._uniformDataView = new DataView(shader._uniformData.buffer); shader._uniformBuffer = this.device.createBuffer({ - size: uniformSize, + size: alignedSize, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, }); @@ -643,16 +647,16 @@ class RendererWebGPU extends Renderer3D { ////////////////////////////////////////////// _packUniforms(shader) { - let offset = 0; for (const name in shader.uniforms) { const uniform = shader.uniforms[name]; if (uniform.isSampler) continue; - if (uniform.size === 1) { - shader._uniformData.set([uniform._cachedData], offset); + if (uniform.type === 'u32') { + shader._uniformDataView.setUint32(uniform.offset, uniform._cachedData, true); + } else if (uniform.size === 4) { + shader._uniformData.set([uniform._cachedData], uniform.offset / 4); } else { - shader._uniformData.set(uniform._cachedData, offset); + shader._uniformData.set(uniform._cachedData, uniform.offset / 4); } - offset += uniform.alignedBytes / 4; } } @@ -668,33 +672,101 @@ class RendererWebGPU extends Renderer3D { const elements = {}; let match; let index = 0; + let offset = 0; const elementRegex = - /(?:@location\((\d+)\)\s+)?(\w+):\s+((?:mat[234]x[234]|vec[234]|float|int|uint|bool|f32|i32|u32|bool)(?:)?)/g + /(?:@location\((\d+)\)\s+)?(\w+):\s*([^\n]+?),?\n/g + + const baseAlignAndSize = (type) => { + if (['f32', 'i32', 'u32', 'bool'].includes(type)) { + return { align: 4, size: 4, items: 1 }; + } + if (/^vec[2-4](|f)$/.test(type)) { + const n = parseInt(type.match(/^vec([2-4])/)[1]); + const size = 4 * n; + const align = n === 2 ? 8 : 16; + return { align, size, items: n }; + } + if (/^mat[2-4](?:x[2-4])?(|f)$/.test(type)) { + if (type[4] === 'x' && type[3] !== type[5]) { + throw new Error('Non-square matrices not implemented yet'); + } + const dim = parseInt(type[3]); + const align = dim === 2 ? 8 : 16; + // Each column must be aligned + const size = Math.ceil(dim * 4 / align) * align * dim; + const pack = dim === 3 + ? (data) => [ + ...data.slice(0, 3), + 0, + ...data.slice(3, 6), + 0, + ...data.slice(6, 9), + 0 + ] + : undefined; + return { align, size, pack, items: dim * dim }; + } + if (/^array<.+>$/.test(type)) { + const [, subtype, rawLength] = type.match(/^array<(.+),\s*(\d+)>/); + const length = parseInt(rawLength); + const { + align: elemAlign, + size: elemSize, + items: elemItems, + pack: elemPack = (data) => [...data] + } = baseAlignAndSize(subtype); + const stride = Math.ceil(elemSize / elemAlign) * elemAlign; + const pack = (data) => { + const result = []; + for (let i = 0; i < data.length; i += elemItems) { + const elemData = elemPack(data.slice(i, elemItems)) + result.push(...elemData); + for (let j = 0; j < stride / 4 - elemData.length; j++) { + result.push(0); + } + } + return result; + }; + return { + align: elemAlign, + size: stride * length, + items: elemItems * length, + pack, + }; + } + throw new Error(`Unknown type in WGSL struct: ${type}`); + }; + while ((match = elementRegex.exec(structBody)) !== null) { const [_, location, name, type] = match; - const size = type.startsWith('vec') - ? parseInt(type[3]) - : type.startsWith('mat') - ? Math.pow(parseInt(type[3]), 2) - : 1; - const bytes = 4 * size; // TODO handle non 32 bit sizes? - const alignedBytes = Math.ceil(bytes / 16) * 16; + const { size, align, pack } = baseAlignAndSize(type); + offset = Math.ceil(offset / align) * align; + const offsetEnd = offset + size; elements[name] = { name, location: location ? parseInt(location) : undefined, index, type, size, - bytes, - alignedBytes, + offset, + offsetEnd, + pack }; index++; + offset = offsetEnd; } return elements; } + _mapUniformData(uniform, data) { + if (uniform.pack) { + return uniform.pack(data); + } + return data; + } + _getShaderAttributes(shader) { const mainMatch = /fn main\(.+:\s*(\S+)\s*\)/.exec(shader._vertSrc); if (!mainMatch) throw new Error("Can't find `fn main` in vertex shader source"); @@ -810,6 +882,40 @@ class RendererWebGPU extends Renderer3D { gpuTexture.destroy(); } + _getLightShader() { + if (!this._defaultLightShader) { + this._defaultLightShader = new Shader( + this, + materialVertexShader, + materialFragmentShader, + { + vertex: { + "void beforeVertex": "() {}", + "Vertex getObjectInputs": "(inputs: Vertex) { return inputs; }", + "Vertex getWorldInputs": "(inputs: Vertex) { return inputs; }", + "Vertex getCameraInputs": "(inputs: Vertex) { return inputs; }", + "void afterVertex": "() {}", + }, + fragment: { + "void beforeFragment": "() {}", + "Inputs getPixelInputs": "(inputs: Inputs) { return inputs; }", + "vec4f combineColors": `(components: ColorComponents) { + var rgb = vec3(0.0); + rgb += components.diffuse * components.baseColor; + rgb += components.ambient * components.ambientColor; + rgb += components.specular * components.specularColor; + rgb += components.emissive; + return vec4(rgb, components.opacity); + }`, + "vec4f getFinalColor": "(color: vec4) { return color; }", + "void afterFragment": "() {}", + }, + } + ); + } + return this._defaultLightShader; + } + _getColorShader() { if (!this._defaultColorShader) { this._defaultColorShader = new Shader( @@ -858,18 +964,23 @@ class RendererWebGPU extends Renderer3D { // way to add code if a hook is augmented. e.g.: // struct Uniforms { // // @p5 ifdef Vertex getWorldInputs - // uModelMatrix: mat4, - // uViewMatrix: mat4, + // uModelMatrix: mat4f, + // uViewMatrix: mat4f, // // @p5 endif // // @p5 ifndef Vertex getWorldInputs - // uModelViewMatrix: mat4, + // uModelViewMatrix: mat4f, // // @p5 endif // } src = src.replace( /\/\/ @p5 (ifdef|ifndef) (\w+)\s+(\w+)\n((?:(?!\/\/ @p5)(?:.|\n))*)\/\/ @p5 endif/g, (_, condition, hookType, hookName, body) => { const target = condition === 'ifdef'; - if (!!shader.hooks.modified[shaderType][`${hookType} ${hookName}`] === target) { + if ( + ( + shader.hooks.modified.vertex[`${hookType} ${hookName}`] || + shader.hooks.modified.fragment[`${hookType} ${hookName}`] + ) === target + ) { return body; } else { return ''; @@ -921,7 +1032,6 @@ class RendererWebGPU extends Renderer3D { } } - console.log(preMain + '\n' + defines + hooks + main + postMain) return preMain + '\n' + defines + hooks + main + postMain; } } diff --git a/src/webgpu/shaders/color.js b/src/webgpu/shaders/color.js index aa82c347b6..b22818efa2 100644 --- a/src/webgpu/shaders/color.js +++ b/src/webgpu/shaders/color.js @@ -14,7 +14,7 @@ struct Uniforms { // @p5 endif uProjectionMatrix: mat4x4, uMaterialColor: vec4, - uUseVertexColor: f32, + uUseVertexColor: u32, }; `; @@ -48,7 +48,7 @@ fn main(input: VertexInput) -> VertexOutput { HOOK_beforeVertex(); var output: VertexOutput; - let useVertexColor = (uniforms.uUseVertexColor != 0.0); + let useVertexColor = (uniforms.uUseVertexColor != 0); var inputs = Vertex( input.aPosition, input.aNormal, @@ -107,9 +107,8 @@ ${getTexture} @fragment fn main(input: FragmentInput) -> @location(0) vec4 { HOOK_beforeFragment(); - var outColor = HOOK_getFinalColor( - vec4(input.vColor.rgb * input.vColor.a, input.vColor.a) - ); + var outColor = HOOK_getFinalColor(input.vColor); + outColor = vec4(outColor.rgb * outColor.a, outColor.a); HOOK_afterFragment(); return outColor; } diff --git a/src/webgpu/shaders/material.js b/src/webgpu/shaders/material.js new file mode 100644 index 0000000000..9722daad06 --- /dev/null +++ b/src/webgpu/shaders/material.js @@ -0,0 +1,348 @@ +import { getTexture } from './utils'; + +const uniforms = ` +struct Uniforms { +// @p5 ifdef Vertex getWorldInputs + uModelMatrix: mat4x4, + uModelNormalMatrix: mat3x3, + uCameraNormalMatrix: mat3x3, +// @p5 endif +// @p5 ifndef Vertex getWorldInputs + uModelViewMatrix: mat4x4, + uNormalMatrix: mat3x3, +// @p5 endif + uViewMatrix: mat4x4, + uProjectionMatrix: mat4x4, + uMaterialColor: vec4, + uUseVertexColor: u32, + + uHasSetAmbient: u32, + uAmbientColor: vec3, + uSpecularMatColor: vec4, + uAmbientMatColor: vec4, + uEmissiveMatColor: vec4, + + uTint: vec4, + isTexture: u32, + + uCameraRotation: mat3x3, + + uDirectionalLightCount: i32, + uLightingDirection: array, 5>, + uDirectionalDiffuseColors: array, 5>, + uDirectionalSpecularColors: array, 5>, + + uPointLightCount: i32, + uPointLightLocation: array, 5>, + uPointLightDiffuseColors: array, 5>, + uPointLightSpecularColors: array, 5>, + + uSpotLightCount: i32, + uSpotLightAngle: vec4, + uSpotLightConc: vec4, + uSpotLightDiffuseColors: array, 4>, + uSpotLightSpecularColors: array, 4>, + uSpotLightLocation: array, 4>, + uSpotLightDirection: array, 4>, + + uSpecular: u32, + uShininess: f32, + uMetallic: f32, + + uConstantAttenuation: f32, + uLinearAttenuation: f32, + uQuadraticAttenuation: f32, + + uUseImageLight: u32, + uUseLighting: u32, +}; +`; + +export const materialVertexShader = ` +struct VertexInput { + @location(0) aPosition: vec3, + @location(1) aNormal: vec3, + @location(2) aTexCoord: vec2, + @location(3) aVertexColor: vec4, +}; + +struct VertexOutput { + @builtin(position) Position: vec4, + @location(0) vNormal: vec3, + @location(1) vTexCoord: vec2, + @location(2) vViewPosition: vec3, + @location(4) vColor: vec4, +}; + +${uniforms} +@group(0) @binding(0) var uniforms: Uniforms; + +struct Vertex { + position: vec3, + normal: vec3, + texCoord: vec2, + color: vec4, +} + +@vertex +fn main(input: VertexInput) -> VertexOutput { + HOOK_beforeVertex(); + var output: VertexOutput; + + let useVertexColor = (uniforms.uUseVertexColor != 0); + var inputs = Vertex( + input.aPosition, + input.aNormal, + input.aTexCoord, + select(uniforms.uMaterialColor, input.aVertexColor, useVertexColor) + ); + +// @p5 ifdef Vertex getObjectInputs + inputs = HOOK_getObjectInputs(inputs); +// @p5 endif + +// @p5 ifdef Vertex getWorldInputs + inputs.position = (uniforms.uModelMatrix * vec4(inputs.position, 1.0)).xyz; + inputs.normal = uniforms.uModelNormalMatrix * inputs.normal; + inputs = HOOK_getWorldInputs(inputs); +// @p5 endif + +// @p5 ifdef Vertex getWorldInputs + // Already multiplied by the model matrix, just apply view + inputs.position = (uniforms.uViewMatrix * vec4(inputs.position, 1.0)).xyz; + inputs.normal = uniforms.uCameraNormalMatrix * inputs.normal; +// @p5 endif +// @p5 ifndef Vertex getWorldInputs + // Apply both at once + inputs.position = (uniforms.uModelViewMatrix * vec4(inputs.position, 1.0)).xyz; + inputs.normal = uniforms.uNormalMatrix * inputs.normal; +// @p5 endif + +// @p5 ifdef Vertex getCameraInputs + inputs = HOOK_getCameraInputs(inputs); +// @p5 endif + + output.vViewPosition = inputs.position; + output.vTexCoord = inputs.texCoord; + output.vNormal = normalize(inputs.normal); + output.vColor = inputs.color; + + output.Position = uniforms.uProjectionMatrix * vec4(inputs.position, 1.0); + + HOOK_afterVertex(); + return output; +} +`; + +export const materialFragmentShader = ` +struct FragmentInput { + @location(0) vNormal: vec3, + @location(1) vTexCoord: vec2, + @location(2) vViewPosition: vec3, + @location(4) vColor: vec4, +}; + +${uniforms} +@group(0) @binding(0) var uniforms: Uniforms; + +struct ColorComponents { + baseColor: vec3, + opacity: f32, + ambientColor: vec3, + specularColor: vec3, + diffuse: vec3, + ambient: vec3, + specular: vec3, + emissive: vec3, +} + +struct Inputs { + normal: vec3, + texCoord: vec2, + ambientLight: vec3, + ambientMaterial: vec3, + specularMaterial: vec3, + emissiveMaterial: vec3, + color: vec4, + shininess: f32, + metalness: f32, +} + +${getTexture} + +struct LightResult { + diffuse: vec3, + specular: vec3, +} +struct LightIntensityResult { + diffuse: f32, + specular: f32, +} + +const specularFactor = 2.0; +const diffuseFactor = 0.73; + +fn phongSpecular( + lightDirection: vec3, + viewDirection: vec3, + surfaceNormal: vec3, + shininess: f32 +) -> f32 { + let R = reflect(lightDirection, surfaceNormal); + return pow(max(0.0, dot(R, viewDirection)), shininess); +} + +fn lambertDiffuse(lightDirection: vec3, surfaceNormal: vec3) -> f32 { + return max(0.0, dot(-lightDirection, surfaceNormal)); +} + +fn singleLight( + viewDirection: vec3, + normal: vec3, + lightVector: vec3, + shininess: f32, + metallic: f32 +) -> LightIntensityResult { + let lightDir = normalize(lightVector); + let specularIntensity = mix(1.0, 0.4, metallic); + let diffuseIntensity = mix(1.0, 0.1, metallic); + let diffuse = lambertDiffuse(lightDir, normal) * diffuseIntensity; + let specular = select( + 0., + phongSpecular(lightDir, viewDirection, normal, shininess) * specularIntensity, + uniforms.uSpecular == 1 + ); + return LightIntensityResult(diffuse, specular); +} + +fn totalLight( + modelPosition: vec3, + normal: vec3, + shininess: f32, + metallic: f32 +) -> LightResult { + var totalSpecular = vec3(0.0, 0.0, 0.0); + var totalDiffuse = vec3(0.0, 0.0, 0.0); + + if (uniforms.uUseLighting == 0) { + return LightResult(vec3(1.0, 1.0, 1.0), totalSpecular); + } + + let viewDirection = normalize(-modelPosition); + + for (var j = 0; j < 5; j++) { + if (j < uniforms.uDirectionalLightCount) { + let lightVector = (uniforms.uViewMatrix * vec4( + uniforms.uLightingDirection[j], + 0.0 + )).xyz; + let lightColor = uniforms.uDirectionalDiffuseColors[j]; + let specularColor = uniforms.uDirectionalSpecularColors[j]; + let result = singleLight(viewDirection, normal, lightVector, shininess, metallic); + totalDiffuse += result.diffuse * lightColor; + totalSpecular += result.specular * specularColor; + } + + if (j < uniforms.uPointLightCount) { + let lightPosition = (uniforms.uViewMatrix * vec4( + uniforms.uPointLightLocation[j], + 1.0 + )).xyz; + let lightVector = modelPosition - lightPosition; + let lightDistance = length(lightVector); + let lightFalloff = 1.0 / ( + uniforms.uConstantAttenuation + + lightDistance * uniforms.uLinearAttenuation + + lightDistance * lightDistance * uniforms.uQuadraticAttenuation + ); + let lightColor = uniforms.uPointLightDiffuseColors[j] * lightFalloff; + let specularColor = uniforms.uPointLightSpecularColors[j] * lightFalloff; + let result = singleLight(viewDirection, normal, lightVector, shininess, metallic); + totalDiffuse += result.diffuse * lightColor; + totalSpecular += result.specular * specularColor; + } + + if (j < uniforms.uSpotLightCount) { + let lightPosition = (uniforms.uViewMatrix * vec4( + uniforms.uSpotLightLocation[j], + 1.0 + )).xyz; + let lightVector = modelPosition - lightPosition; + let lightDistance = length(lightVector); + var lightFalloff = 1.0 / ( + uniforms.uConstantAttenuation + + lightDistance * uniforms.uLinearAttenuation + + lightDistance * lightDistance * uniforms.uQuadraticAttenuation + ); + let lightDirection = (uniforms.uViewMatrix * vec4( + uniforms.uSpotLightDirection[j], + 0.0 + )).xyz; + let spotDot = dot(normalize(lightVector), normalize(lightDirection)); + let spotFalloff = select( + 0.0, + pow(spotDot, uniforms.uSpotLightConc[j]), + spotDot < uniforms.uSpotLightAngle[j] + ); + lightFalloff *= spotFalloff; + let lightColor = uniforms.uSpotLightDiffuseColors[j]; + let specularColor = uniforms.uSpotLightSpecularColors[j]; + let result = singleLight(viewDirection, normal, lightVector, shininess, metallic); + totalDiffuse += result.diffuse * lightColor; + totalSpecular += result.specular * specularColor; + } + } + + // TODO: image light + + return LightResult( + totalDiffuse * diffuseFactor, + totalSpecular * specularFactor + ); +} + +@fragment +fn main(input: FragmentInput) -> @location(0) vec4 { + HOOK_beforeFragment(); + + let color = input.vColor; // TODO: check isTexture and apply tint + var inputs = Inputs( + normalize(input.vNormal), + input.vTexCoord, + uniforms.uAmbientColor, + select(color.rgb, uniforms.uAmbientMatColor.rgb, uniforms.uHasSetAmbient == 1), + uniforms.uSpecularMatColor.rgb, + uniforms.uEmissiveMatColor.rgb, + color, + uniforms.uShininess, + uniforms.uMetallic + ); + inputs = HOOK_getPixelInputs(inputs); + + let light = totalLight( + input.vViewPosition, + inputs.normal, + inputs.shininess, + inputs.metalness + ); + + let baseColor = inputs.color; + let components = ColorComponents( + baseColor.rgb, + baseColor.a, + inputs.ambientMaterial, + inputs.specularMaterial, + light.diffuse, + inputs.ambientLight, + light.specular, + inputs.emissiveMaterial + ); + + var outColor = HOOK_getFinalColor( + HOOK_combineColors(components) + ); + outColor = vec4(outColor.rgb * outColor.a, outColor.a); + HOOK_afterFragment(); + return outColor; +} +`; From 5db2d3331476e490e40a48ff9431212c019f7147 Mon Sep 17 00:00:00 2001 From: lukeplowden Date: Mon, 16 Jun 2025 12:40:27 +0100 Subject: [PATCH 12/72] changed drawbuffers to draw stroke and fill buffers depending on current shader --- src/core/p5.Renderer3D.js | 4 +-- src/webgpu/p5.RendererWebGPU.js | 46 +++++++++++++++++---------------- 2 files changed, 26 insertions(+), 24 deletions(-) diff --git a/src/core/p5.Renderer3D.js b/src/core/p5.Renderer3D.js index a2e40d83c5..223382f141 100644 --- a/src/core/p5.Renderer3D.js +++ b/src/core/p5.Renderer3D.js @@ -557,7 +557,7 @@ export class Renderer3D extends Renderer { geometry.hasFillTransparency() ); - this._drawBuffers(geometry, { mode, count }, false); + this._drawBuffers(geometry, { mode, count }); shader.unbindShader(); } @@ -583,7 +583,7 @@ export class Renderer3D extends Renderer { geometry.hasStrokeTransparency() ); - this._drawBuffers(geometry, {count}, true) + this._drawBuffers(geometry, {count}) shader.unbindShader(); } diff --git a/src/webgpu/p5.RendererWebGPU.js b/src/webgpu/p5.RendererWebGPU.js index ffb012e1f1..5d1439c855 100644 --- a/src/webgpu/p5.RendererWebGPU.js +++ b/src/webgpu/p5.RendererWebGPU.js @@ -548,7 +548,7 @@ class RendererWebGPU extends Renderer3D { // Rendering ////////////////////////////////////////////// - _drawBuffers(geometry, { mode = constants.TRIANGLES, count = 1 }, stroke) { + _drawBuffers(geometry, { mode = constants.TRIANGLES, count = 1 }) { const buffers = this.geometryBufferCache.getCached(geometry); if (!buffers) return; @@ -578,33 +578,33 @@ class RendererWebGPU extends Renderer3D { }; const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor); - passEncoder.setPipeline(this._curShader.getPipeline(this._shaderOptions({ mode }))); + const currentShader = this._curShader; + passEncoder.setPipeline(currentShader.getPipeline(this._shaderOptions({ mode }))); // Bind vertex buffers - for (const buffer of this._getVertexBuffers(this._curShader)) { + for (const buffer of this._getVertexBuffers(currentShader)) { passEncoder.setVertexBuffer( - this._curShader.attributes[buffer.attr].location, + currentShader.attributes[buffer.attr].location, buffers[buffer.dst], 0 ); } // Bind uniforms this._packUniforms(this._curShader); - console.log(this._curShader); this.device.queue.writeBuffer( - this._curShader._uniformBuffer, + currentShader._uniformBuffer, 0, - this._curShader._uniformData.buffer, - this._curShader._uniformData.byteOffset, - this._curShader._uniformData.byteLength + currentShader._uniformData.buffer, + currentShader._uniformData.byteOffset, + currentShader._uniformData.byteLength ); // Bind sampler/texture uniforms - for (const [group, entries] of this._curShader._groupEntries) { + for (const [group, entries] of currentShader._groupEntries) { const bgEntries = entries.map(entry => { if (group === 0 && entry.binding === 0) { return { binding: 0, - resource: { buffer: this._curShader._uniformBuffer }, + resource: { buffer: currentShader._uniformBuffer }, }; } @@ -616,7 +616,7 @@ class RendererWebGPU extends Renderer3D { }; }); - const layout = this._curShader._bindGroupLayouts[group]; + const layout = currentShader._bindGroupLayouts[group]; const bindGroup = this.device.createBindGroup({ layout, entries: bgEntries, @@ -624,18 +624,20 @@ class RendererWebGPU extends Renderer3D { passEncoder.setBindGroup(group, bindGroup); } - if (buffers.lineVerticesBuffer && geometry.lineVertices && stroke) { + if (currentShader.shaderType === "fill") { + // Bind index buffer and issue draw + if (buffers.indexBuffer) { + const indexFormat = buffers.indexFormat || "uint16"; + passEncoder.setIndexBuffer(buffers.indexBuffer, indexFormat); + passEncoder.drawIndexed(geometry.faces.length * 3, count, 0, 0, 0); + } else { + passEncoder.draw(geometry.vertices.length, count, 0, 0); + } + } + + if (buffers.lineVerticesBuffer && currentShader.shaderType === "stroke") { passEncoder.draw(geometry.lineVertices.length / 3, count, 0, 0); } - // Bind index buffer and issue draw - if (!stroke) { - if (buffers.indexBuffer) { - const indexFormat = buffers.indexFormat || "uint16"; - passEncoder.setIndexBuffer(buffers.indexBuffer, indexFormat); - passEncoder.drawIndexed(geometry.faces.length * 3, count, 0, 0, 0); - } else { - passEncoder.draw(geometry.vertices.length, count, 0, 0); - }} passEncoder.end(); this.queue.submit([commandEncoder.finish()]); From a116e6fa7b7df59fb695b4a59e4faac55ba7836d Mon Sep 17 00:00:00 2001 From: lukeplowden Date: Mon, 16 Jun 2025 12:42:46 +0100 Subject: [PATCH 13/72] fixed uViewport uniform (uniform problem fixed in upstream) --- src/core/p5.Renderer3D.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/p5.Renderer3D.js b/src/core/p5.Renderer3D.js index 223382f141..a487d975f9 100644 --- a/src/core/p5.Renderer3D.js +++ b/src/core/p5.Renderer3D.js @@ -1414,7 +1414,7 @@ export class Renderer3D extends Renderer { this.scratchMat3.inverseTranspose4x4(this.states.uViewMatrix); shader.setUniform("uCameraRotation", this.scratchMat3.mat3); } - shader.setUniform("uViewport", [0, 0, 400, 400]); + shader.setUniform("uViewport", this._viewport); } _setStrokeUniforms(strokeShader) { From ddfeb05839ab830bed84fc2105a95af4e8637652 Mon Sep 17 00:00:00 2001 From: lukeplowden Date: Mon, 16 Jun 2025 12:43:24 +0100 Subject: [PATCH 14/72] remove console log --- src/webgpu/p5.RendererWebGPU.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/webgpu/p5.RendererWebGPU.js b/src/webgpu/p5.RendererWebGPU.js index 5d1439c855..acbfa20661 100644 --- a/src/webgpu/p5.RendererWebGPU.js +++ b/src/webgpu/p5.RendererWebGPU.js @@ -948,7 +948,6 @@ class RendererWebGPU extends Renderer3D { } } - //console.log(preMain + '\n' + defines + hooks + main + postMain) return preMain + '\n' + defines + hooks + main + postMain; } } From 14f1857fddbf30d66c890158664a6f7639c5e348 Mon Sep 17 00:00:00 2001 From: lukeplowden Date: Mon, 16 Jun 2025 12:44:09 +0100 Subject: [PATCH 15/72] remove unused variable --- src/webgl/p5.Shader.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/webgl/p5.Shader.js b/src/webgl/p5.Shader.js index 3f4015311c..b95066a70d 100644 --- a/src/webgl/p5.Shader.js +++ b/src/webgl/p5.Shader.js @@ -983,7 +983,6 @@ class Shader { if (!uniform) { return; } - const gl = this._renderer.GL; if (uniform.isArray) { if ( From 2c19cdcaa0abaf05476e8413ec9130d1392f14de Mon Sep 17 00:00:00 2001 From: lukeplowden Date: Mon, 16 Jun 2025 12:45:30 +0100 Subject: [PATCH 16/72] remove hardcoded viewport (uniform issue fixed upstream) --- src/webgpu/shaders/line.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/webgpu/shaders/line.js b/src/webgpu/shaders/line.js index a42e7b178e..15c3684886 100644 --- a/src/webgpu/shaders/line.js +++ b/src/webgpu/shaders/line.js @@ -75,7 +75,6 @@ fn lineIntersection(aPoint: vec2f, aDir: vec2f, bPoint: vec2f, bDir: vec2f) -> v fn main(input: StrokeVertexInput) -> StrokeVertexOutput { HOOK_beforeVertex(); var output: StrokeVertexOutput; - let viewport = vec4(0.,0.,400.,400.); let simpleLines = (uniforms.uSimpleLines != 0.); if (!simpleLines) { if (all(input.aTangentIn == vec3()) != all(input.aTangentOut == vec3())) { @@ -219,7 +218,7 @@ fn main(input: StrokeVertexInput) -> StrokeVertexOutput { if (sideEnum == 2.) { // Calculate the position + tangent on either side of the join, and // find where the lines intersect to find the elbow of the join - var c = (posp.xy / posp.w + vec2(1.)) * 0.5 * viewport.zw; + var c = (posp.xy / posp.w + vec2(1.)) * 0.5 * uniforms.uViewport.zw; var intersection = lineIntersection( c + (side * normalIn * inputs.weight / 2.), From e7696f067a45c3f7afc9c5ccdcc9b386790fa1de Mon Sep 17 00:00:00 2001 From: lukeplowden Date: Mon, 16 Jun 2025 12:46:01 +0100 Subject: [PATCH 17/72] fix stroke shader bugs from porting process) --- src/webgpu/shaders/line.js | 29 ++++++++++------------------- 1 file changed, 10 insertions(+), 19 deletions(-) diff --git a/src/webgpu/shaders/line.js b/src/webgpu/shaders/line.js index 15c3684886..96402aa1ee 100644 --- a/src/webgpu/shaders/line.js +++ b/src/webgpu/shaders/line.js @@ -179,8 +179,8 @@ fn main(input: StrokeVertexInput) -> StrokeVertexOutput { var qIn = uniforms.uProjectionMatrix * posqIn; var qOut = uniforms.uProjectionMatrix * posqOut; - var tangentIn = normalize((qIn.xy * p.w - p.xy * qIn.w) * viewport.zw); - var tangentOut = normalize((qOut.xy * p.w - p.xy * qOut.w) * viewport.zw); + var tangentIn = normalize((qIn.xy * p.w - p.xy * qIn.w) * uniforms.uViewport.zw); + var tangentOut = normalize((qOut.xy * p.w - p.xy * qOut.w) * uniforms.uViewport.zw); var curPerspScale = vec2(); if (uniforms.uPerspective == 1) { @@ -195,7 +195,7 @@ fn main(input: StrokeVertexInput) -> StrokeVertexOutput { // No Perspective --- // multiply by W (to cancel out division by W later in the pipeline) and // convert from screen to clip (derived from clip to screen above) - curPerspScale = p.w / (0.5 * viewport.zw); + curPerspScale = p.w / (0.5 * uniforms.uViewport.zw); } var offset = vec2(); @@ -234,14 +234,14 @@ fn main(input: StrokeVertexInput) -> StrokeVertexOutput { // the magnitude to avoid lines going across the whole screen when this // happens. var mag = length(offset); - var maxMag = 3 * inputs.weight; + var maxMag = 3. * inputs.weight; if (mag > maxMag) { - offset = vec2(maxMag / mag); - } else if (sideEnum == 1.) { + offset *= maxMag / mag; + } + } else if (sideEnum == 1.) { offset = side * normalIn * inputs.weight / 2.; - } else if (sideEnum == 3.) { + } else if (sideEnum == 3.) { offset = side * normalOut * inputs.weight / 2.; - } } } if (uniforms.uStrokeJoin == 2) { @@ -258,7 +258,7 @@ fn main(input: StrokeVertexInput) -> StrokeVertexOutput { tangent = tangentIn; } output.vTangent = tangent; - var normal = vec2(-tangent.y, tangent.y); + var normal = vec2(-tangent.y, tangent.x); var normalOffset = sign(input.aSide); // Caps will have side values of -2 or 2 on the edge of the cap that @@ -274,17 +274,8 @@ fn main(input: StrokeVertexInput) -> StrokeVertexOutput { output.Position = vec4( p.xy + offset.xy * curPerspScale, - p.zy + p.zw ); - var clip_pos: vec4; - if (input.aSide == 1.0) { - clip_pos = vec4(-0.1, 0.1, 0.5, 1.); - } else if (input.aSide == -1.0) { - clip_pos = vec4(-0.5, 0.5, 0.5, 1.0); - } else { - clip_pos = vec4(0.0, -0.5, 0.5 ,1.0); - } - output.Position = clip_pos; return output; } From ed88f919539a6cdbeb8179bc512198421d6f4413 Mon Sep 17 00:00:00 2001 From: lukeplowden Date: Mon, 16 Jun 2025 12:46:45 +0100 Subject: [PATCH 18/72] stroke test --- preview/index.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/preview/index.html b/preview/index.html index 016dd60172..5b141511c1 100644 --- a/preview/index.html +++ b/preview/index.html @@ -36,10 +36,10 @@ // p.noStroke(); for (const [i, c] of ['red'].entries()) { p.stroke(0); - p.strokeWeight(10); + p.strokeWeight(2); p.push(); p.fill(c); - p.sphere(60, 4, 2); + p.sphere(60); p.pop(); } }; From ce4b31e9b1fcb1b3952462250a721df826a35520 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Mon, 16 Jun 2025 09:15:35 -0400 Subject: [PATCH 19/72] Coerce modified hooks to boolean --- src/webgpu/p5.RendererWebGPU.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/webgpu/p5.RendererWebGPU.js b/src/webgpu/p5.RendererWebGPU.js index a2bcaca885..fa738f852c 100644 --- a/src/webgpu/p5.RendererWebGPU.js +++ b/src/webgpu/p5.RendererWebGPU.js @@ -977,8 +977,8 @@ class RendererWebGPU extends Renderer3D { const target = condition === 'ifdef'; if ( ( - shader.hooks.modified.vertex[`${hookType} ${hookName}`] || - shader.hooks.modified.fragment[`${hookType} ${hookName}`] + !!shader.hooks.modified.vertex[`${hookType} ${hookName}`] || + !!shader.hooks.modified.fragment[`${hookType} ${hookName}`] ) === target ) { return body; From 4dc493dbe6ae986c5ef738cef6119c2986f5b7f1 Mon Sep 17 00:00:00 2001 From: lukeplowden Date: Mon, 16 Jun 2025 16:19:29 +0100 Subject: [PATCH 20/72] add stroke to preview --- preview/index.html | 49 +++++++++++++++++++++++++++++++++++++--------- 1 file changed, 40 insertions(+), 9 deletions(-) diff --git a/preview/index.html b/preview/index.html index 5b141511c1..99a5bc38d5 100644 --- a/preview/index.html +++ b/preview/index.html @@ -25,21 +25,52 @@ const sketch = function (p) { let fbo; let sh; + let ssh; + p.setup = async function () { await p.createCanvas(400, 400, p.WEBGPU); + sh = p.baseMaterialShader().modify({ + uniforms: { + 'f32 time': () => p.millis(), + }, + 'Vertex getWorldInputs': `(inputs: Vertex) { + var result = inputs; + result.position.y += 40.0 * sin(uniforms.time * 0.01); + return result; + }`, + }) + ssh = p.baseStrokeShader().modify({ + uniforms: { + 'f32 time': () => p.millis(), + }, + 'StrokeVertex getWorldInputs': `(inputs: StrokeVertex) { + var result = inputs; + result.position.y += 40.0 * sin(uniforms.time * 0.01); + return result; + }`, + }) }; - p.disableFriendlyErrors = true; + p.draw = function () { - p.orbitControl() const t = p.millis() * 0.008; - p.background(0); - // p.noStroke(); - for (const [i, c] of ['red'].entries()) { - p.stroke(0); - p.strokeWeight(2); - p.push(); + p.background(200); + p.shader(sh); + p.strokeShader(ssh) + p.ambientLight(50); + p.directionalLight(100, 100, 100, 0, 1, -1); + p.pointLight(155, 155, 155, 0, -200, 500); + p.specularMaterial(255); + p.shininess(300); + p.stroke('white') + for (const [i, c] of ['red', 'lime', 'blue'].entries()) { + p.push(); p.fill(c); - p.sphere(60); + p.translate( + p.width/3 * p.sin(t + i * Math.E), + 0, //p.width/3 * p.sin(t * 0.9 + i * Math.E + 0.2), + p.width/3 * p.sin(t * 1.2 + i * Math.E + 0.3), + ) + p.sphere(30); p.pop(); } }; From d88fba92a6ab643d0205b02e41e107230ad3b647 Mon Sep 17 00:00:00 2001 From: lukeplowden Date: Mon, 16 Jun 2025 16:21:44 +0100 Subject: [PATCH 21/72] change stroke shader constants/ preprocessor to be compatible with webgpu and webgl --- src/core/p5.Renderer3D.js | 8 ++++---- src/webgl/p5.RendererGL.js | 2 +- src/webgpu/p5.RendererWebGPU.js | 6 +++++- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/core/p5.Renderer3D.js b/src/core/p5.Renderer3D.js index 394462f3d7..c437f34725 100644 --- a/src/core/p5.Renderer3D.js +++ b/src/core/p5.Renderer3D.js @@ -16,16 +16,16 @@ import { RenderBuffer } from "../webgl/p5.RenderBuffer"; import { Image } from "../image/p5.Image"; import { Texture } from "../webgl/p5.Texture"; -export function getStrokeDefs() { +export function getStrokeDefs(shaderConstant) { const STROKE_CAP_ENUM = {}; const STROKE_JOIN_ENUM = {}; let lineDefs = ""; const defineStrokeCapEnum = function (key, val) { - lineDefs += `#define STROKE_CAP_${key} ${val}\n`; + lineDefs += shaderConstant(`STROKE_CAP_${key}`, `${val}`, 'u32'); STROKE_CAP_ENUM[constants[key]] = val; }; const defineStrokeJoinEnum = function (key, val) { - lineDefs += `#define STROKE_JOIN_${key} ${val}\n`; + lineDefs += shaderConstant(`STROKE_JOIN_${key}`, `${val}`, 'u32'); STROKE_JOIN_ENUM[constants[key]] = val; }; @@ -41,7 +41,7 @@ export function getStrokeDefs() { return { STROKE_CAP_ENUM, STROKE_JOIN_ENUM, lineDefs }; } -const { STROKE_CAP_ENUM, STROKE_JOIN_ENUM } = getStrokeDefs(); +const { STROKE_CAP_ENUM, STROKE_JOIN_ENUM } = getStrokeDefs(()=>""); export class Renderer3D extends Renderer { constructor(pInst, w, h, isMainCanvas, elt) { diff --git a/src/webgl/p5.RendererGL.js b/src/webgl/p5.RendererGL.js index e033e3b1c2..6cc8273a66 100644 --- a/src/webgl/p5.RendererGL.js +++ b/src/webgl/p5.RendererGL.js @@ -39,7 +39,7 @@ import filterInvertFrag from "./shaders/filters/invert.frag"; import filterThresholdFrag from "./shaders/filters/threshold.frag"; import filterShaderVert from "./shaders/filters/default.vert"; -const { lineDefs } = getStrokeDefs(); +const { lineDefs } = getStrokeDefs((n, v) => `#define ${n} ${v};\n`); const defaultShaders = { normalVert, diff --git a/src/webgpu/p5.RendererWebGPU.js b/src/webgpu/p5.RendererWebGPU.js index 61e4254675..9af8bb4256 100644 --- a/src/webgpu/p5.RendererWebGPU.js +++ b/src/webgpu/p5.RendererWebGPU.js @@ -1,10 +1,14 @@ -import { Renderer3D } from '../core/p5.Renderer3D'; +import { Renderer3D, getStrokeDefs } from '../core/p5.Renderer3D'; import { Shader } from '../webgl/p5.Shader'; import * as constants from '../core/constants'; + + import { colorVertexShader, colorFragmentShader } from './shaders/color'; import { lineVertexShader, lineFragmentShader} from './shaders/line'; import { materialVertexShader, materialFragmentShader } from './shaders/material'; +const { lineDefs } = getStrokeDefs((n, v, t) => `const ${n}: ${t} = ${v};\n`); + class RendererWebGPU extends Renderer3D { constructor(pInst, w, h, isMainCanvas, elt) { super(pInst, w, h, isMainCanvas, elt) From c48977aa13eb23f9422399783f93f584784f68dc Mon Sep 17 00:00:00 2001 From: lukeplowden Date: Mon, 16 Jun 2025 16:24:56 +0100 Subject: [PATCH 22/72] rename fillHooks to populateHooks (ambiguous with fill/stroke) --- src/webgl/p5.RendererGL.js | 2 +- src/webgl/p5.Shader.js | 2 +- src/webgpu/p5.RendererWebGPU.js | 19 ++++--- src/webgpu/shaders/line.js | 94 +++++++++++++++++++++++++-------- 4 files changed, 87 insertions(+), 30 deletions(-) diff --git a/src/webgl/p5.RendererGL.js b/src/webgl/p5.RendererGL.js index 6cc8273a66..e05e6839b1 100644 --- a/src/webgl/p5.RendererGL.js +++ b/src/webgl/p5.RendererGL.js @@ -1683,7 +1683,7 @@ class RendererGL extends Renderer3D { ////////////////////////////////////////////// // Shader hooks ////////////////////////////////////////////// - fillHooks(shader, src, shaderType) { + populateHooks(shader, src, shaderType) { const main = 'void main'; if (!src.includes(main)) return src; diff --git a/src/webgl/p5.Shader.js b/src/webgl/p5.Shader.js index fc3745e394..0134701c9c 100644 --- a/src/webgl/p5.Shader.js +++ b/src/webgl/p5.Shader.js @@ -123,7 +123,7 @@ class Shader { } shaderSrc(src, shaderType) { - return this._renderer.fillHooks(this, src, shaderType); + return this._renderer.populateHooks(this, src, shaderType); } /** diff --git a/src/webgpu/p5.RendererWebGPU.js b/src/webgpu/p5.RendererWebGPU.js index 9af8bb4256..5d023e266f 100644 --- a/src/webgpu/p5.RendererWebGPU.js +++ b/src/webgpu/p5.RendererWebGPU.js @@ -954,17 +954,22 @@ class RendererWebGPU extends Renderer3D { if (!this._defaultLineShader) { this._defaultLineShader = new Shader( this, - lineVertexShader, - lineFragmentShader, + lineDefs + lineVertexShader, + lineDefs + lineFragmentShader, { vertex: { "void beforeVertex": "() {}", - "Vertex getObjectInputs": "(inputs: Vertex) { return inputs; }", - "Vertex getWorldInputs": "(inputs: Vertex) { return inputs; }", - "Vertex getCameraInputs": "(inputs: Vertex) { return inputs; }", + "StrokeVertex getObjectInputs": "(inputs: StrokeVertex) { return inputs; }", + "StrokeVertex getWorldInputs": "(inputs: StrokeVertex) { return inputs; }", + "StrokeVertex getCameraInputs": "(inputs: StrokeVertex) { return inputs; }", + "void afterVertex": "() {}", }, fragment: { - "vec4 getFinalColor": "(color: vec4) { return color; }" + "void beforeFragment": "() {}", + "Inputs getPixelInputs": "(inputs: Inputs) { return inputs; }", + "vec4 getFinalColor": "(color: vec4) { return color; }", + "bool shouldDiscard": "(outside: bool) { return outside; };", + "void afterFragment": "() {}", }, } ); @@ -987,7 +992,7 @@ class RendererWebGPU extends Renderer3D { ////////////////////////////////////////////// // Shader hooks ////////////////////////////////////////////// - fillHooks(shader, src, shaderType) { + populateHooks(shader, src, shaderType) { if (!src.includes('fn main')) return src; // Apply some p5-specific preprocessing. WGSL doesn't have preprocessor diff --git a/src/webgpu/shaders/line.js b/src/webgpu/shaders/line.js index 96402aa1ee..0aa9f5e72b 100644 --- a/src/webgpu/shaders/line.js +++ b/src/webgpu/shaders/line.js @@ -2,11 +2,11 @@ import { getTexture } from './utils' const uniforms = ` struct Uniforms { -// @p5 ifdef Vertex getWorldInputs +// @p5 ifdef StrokeVertex getWorldInputs uModelMatrix: mat4x4, uViewMatrix: mat4x4, // @p5 endif -// @p5 ifndef Vertex getWorldInputs +// @p5 ifndef StrokeVertex getWorldInputs uModelViewMatrix: mat4x4, // @p5 endif uMaterialColor: vec4, @@ -15,10 +15,10 @@ struct Uniforms { uUseLineColor: f32, uSimpleLines: f32, uViewport: vec4, - uPerspective: i32, - uStrokeJoin: i32, -} -`; + uPerspective: u32, + uStrokeCap: u32, + uStrokeJoin: u32, +}`; export const lineVertexShader = ` struct StrokeVertexInput { @@ -44,7 +44,7 @@ struct StrokeVertexOutput { ${uniforms} @group(0) @binding(0) var uniforms: Uniforms; -struct Vertex { +struct StrokeVertex { position: vec3, tangentIn: vec3, tangentOut: vec3, @@ -97,7 +97,7 @@ fn main(input: StrokeVertexInput) -> StrokeVertexOutput { } else { lineColor = uniforms.uMaterialColor; } - var inputs = Vertex( + var inputs = StrokeVertex( input.aPosition.xyz, input.aTangentIn, input.aTangentOut, @@ -105,29 +105,30 @@ fn main(input: StrokeVertexInput) -> StrokeVertexOutput { uniforms.uStrokeWeight ); -// @p5 ifdef Vertex getObjectInputs +// @p5 ifdef StrokeVertex getObjectInputs inputs = HOOK_getObjectInputs(inputs); // @p5 endif -// @p5 ifdef Vertex getWorldInputs - inputs.position = (uModelMatrix * vec4(inputs.position, 1.)).xyz; - inputs.tangentIn = (uModelMatrix * vec4(input.aTangentIn, 1.)).xyz; - inputs.tangentOut = (uModelMatrix * vec4(input.aTangentOut, 1.)).xyz; +// @p5 ifdef StrokeVertex getWorldInputs + inputs.position = (uniforms.uModelMatrix * vec4(inputs.position, 1.)).xyz; + inputs.tangentIn = (uniforms.uModelMatrix * vec4(input.aTangentIn, 1.)).xyz; + inputs.tangentOut = (uniforms.uModelMatrix * vec4(input.aTangentOut, 1.)).xyz; + inputs = HOOK_getWorldInputs(inputs); // @p5 endif -// @p5 ifdef Vertex getWorldInputs +// @p5 ifdef StrokeVertex getWorldInputs // Already multiplied by the model matrix, just apply view inputs.position = (uniforms.uViewMatrix * vec4(inputs.position, 1.)).xyz; inputs.tangentIn = (uniforms.uViewMatrix * vec4(input.aTangentIn, 0.)).xyz; inputs.tangentOut = (uniforms.uViewMatrix * vec4(input.aTangentOut, 0.)).xyz; // @p5 endif -// @p5 ifndef Vertex getWorldInputs +// @p5 ifndef StrokeVertex getWorldInputs // Apply both at once inputs.position = (uniforms.uModelViewMatrix * vec4(inputs.position, 1.)).xyz; inputs.tangentIn = (uniforms.uModelViewMatrix * vec4(input.aTangentIn, 0.)).xyz; inputs.tangentOut = (uniforms.uModelViewMatrix * vec4(input.aTangentOut, 0.)).xyz; // @p5 endif -// @p5 ifdef Vertex getCameraInputs +// @p5 ifdef StrokeVertex getCameraInputs inputs = HOOK_getCameraInputs(inputs); // @p5 endif @@ -276,11 +277,9 @@ fn main(input: StrokeVertexInput) -> StrokeVertexOutput { p.xy + offset.xy * curPerspScale, p.zw ); + HOOK_afterVertex(); return output; -} - - -`; +}`; export const lineFragmentShader = ` struct StrokeFragmentInput { @@ -299,9 +298,62 @@ ${uniforms} ${getTexture} +fn distSquared(a: vec2, b: vec2) -> f32 { + return dot(b - a, b - a); +} + +struct Inputs { + color: vec4, + tangent: vec2, + center: vec2, + position: vec2, + strokeWeight: f32, +} + @fragment fn main(input: StrokeFragmentInput) -> @location(0) vec4 { - return vec4(1., 1., 1., 1.); + HOOK_beforeFragment(); + + var inputs: Inputs; + inputs.color = input.vColor; + inputs.tangent = input.vTangent; + inputs.center = input.vCenter; + inputs.position = input.vPosition; + inputs.strokeWeight = input.vStrokeWeight; + inputs = HOOK_getPixelInputs(inputs); + + if (input.vCap > 0.) { + if ( + uniforms.uStrokeCap == STROKE_CAP_ROUND && + HOOK_shouldDiscard(distSquared(inputs.position, inputs.center) > inputs.strokeWeight * inputs.strokeWeight * 0.25) + ) { + discard; + } else if ( + uniforms.uStrokeCap == STROKE_CAP_SQUARE && + HOOK_shouldDiscard(dot(inputs.position - inputs.center, inputs.tangent) > 0.) + ) { + discard; + } else if (HOOK_shouldDiscard(false)) { + discard; + } + } else if (input.vJoin > 0.) { + if ( + uniforms.uStrokeJoin == STROKE_JOIN_ROUND && + HOOK_shouldDiscard(distSquared(inputs.position, inputs.center) > inputs.strokeWeight * inputs.strokeWeight * 0.25) + ) { + discard; + } else if (uniforms.uStrokeJoin == STROKE_JOIN_BEVEL) { + let normal = vec2(-inputs.tangent.y, -inputs.tangent.x); + if (HOOK_shouldDiscard(abs(dot(inputs.position - inputs.center, normal)) > input.vMaxDist)) { + discard; + } + } else if (HOOK_shouldDiscard(false)) { + discard; + } + } + var col = HOOK_getFinalColor(vec4(inputs.color.rgb, 1.) * inputs.color.a); + HOOK_afterFragment(); + return vec4(col); } `; From f0875d795be9297bcdf9b312dffcafeda4741536 Mon Sep 17 00:00:00 2001 From: lukeplowden Date: Mon, 16 Jun 2025 17:40:17 +0100 Subject: [PATCH 23/72] add strokes back to WebGL mode --- src/webgl/p5.RendererGL.js | 42 +++++++++++++++----------------------- 1 file changed, 17 insertions(+), 25 deletions(-) diff --git a/src/webgl/p5.RendererGL.js b/src/webgl/p5.RendererGL.js index e05e6839b1..70adb68c16 100644 --- a/src/webgl/p5.RendererGL.js +++ b/src/webgl/p5.RendererGL.js @@ -270,37 +270,29 @@ class RendererGL extends Renderer3D { } } - // Stroke version for now: - // -// { -// const gl = this.GL; -// // move this to _drawBuffers ? -// if (count === 1) { -// gl.drawArrays(gl.TRIANGLES, 0, geometry.lineVertices.length / 3); -// } else { -// try { - // gl.drawArraysInstanced( - // gl.TRIANGLES, - // 0, - // geometry.lineVertices.length / 3, - // count - // ); - // } catch (e) { - // console.log( - // "🌸 p5.js says: Instancing is only supported in WebGL2 mode" - // ); - // } - // } - // } - _drawBuffers(geometry, { mode = constants.TRIANGLES, count }) { const gl = this.GL; const glBuffers = this.geometryBufferCache.getCached(geometry); - //console.log(glBuffers); if (!glBuffers) return; - if (glBuffers.indexBuffer) { + if (this._curShader.shaderType === 'stroke'){ + if (count === 1) { + gl.drawArrays(gl.TRIANGLES, 0, geometry.lineVertices.length / 3); + } else { + try { + gl.drawArraysInstanced( + gl.TRIANGLES, + 0, + geometry.lineVertices.length / 3, + count + ); + } catch (e) { + console.log( + "🌸 p5.js says: Instancing is only supported in WebGL2 mode" + ); + } + } else if (glBuffers.indexBuffer) { this._bindBuffer(glBuffers.indexBuffer, gl.ELEMENT_ARRAY_BUFFER); // If this model is using a Uint32Array we need to ensure the From e8bedfc470d01df99d2ebaf8d26a0e1f4afd5ad2 Mon Sep 17 00:00:00 2001 From: lukeplowden Date: Mon, 16 Jun 2025 17:40:17 +0100 Subject: [PATCH 24/72] add strokes back to WebGL mode --- src/webgl/p5.RendererGL.js | 43 ++++++++++++++++---------------------- 1 file changed, 18 insertions(+), 25 deletions(-) diff --git a/src/webgl/p5.RendererGL.js b/src/webgl/p5.RendererGL.js index e05e6839b1..01dc2d035e 100644 --- a/src/webgl/p5.RendererGL.js +++ b/src/webgl/p5.RendererGL.js @@ -270,37 +270,30 @@ class RendererGL extends Renderer3D { } } - // Stroke version for now: - // -// { -// const gl = this.GL; -// // move this to _drawBuffers ? -// if (count === 1) { -// gl.drawArrays(gl.TRIANGLES, 0, geometry.lineVertices.length / 3); -// } else { -// try { - // gl.drawArraysInstanced( - // gl.TRIANGLES, - // 0, - // geometry.lineVertices.length / 3, - // count - // ); - // } catch (e) { - // console.log( - // "🌸 p5.js says: Instancing is only supported in WebGL2 mode" - // ); - // } - // } - // } - _drawBuffers(geometry, { mode = constants.TRIANGLES, count }) { const gl = this.GL; const glBuffers = this.geometryBufferCache.getCached(geometry); - //console.log(glBuffers); if (!glBuffers) return; - if (glBuffers.indexBuffer) { + if (this._curShader.shaderType === 'stroke') { + if (count === 1) { + gl.drawArrays(gl.TRIANGLES, 0, geometry.lineVertices.length / 3); + } else { + try { + gl.drawArraysInstanced( + gl.TRIANGLES, + 0, + geometry.lineVertices.length / 3, + count + ); + } catch (e) { + console.log( + "🌸 p5.js says: Instancing is only supported in WebGL2 mode" + ); + } + } + } else if (glBuffers.indexBuffer) { this._bindBuffer(glBuffers.indexBuffer, gl.ELEMENT_ARRAY_BUFFER); // If this model is using a Uint32Array we need to ensure the From 0af8df9e37e94f189bbeb8945f581872fb9639b4 Mon Sep 17 00:00:00 2001 From: lukeplowden Date: Mon, 16 Jun 2025 18:07:51 +0100 Subject: [PATCH 25/72] typo in string --- src/webgpu/p5.RendererWebGPU.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/webgpu/p5.RendererWebGPU.js b/src/webgpu/p5.RendererWebGPU.js index 5d023e266f..0b9dd6fcfa 100644 --- a/src/webgpu/p5.RendererWebGPU.js +++ b/src/webgpu/p5.RendererWebGPU.js @@ -675,7 +675,7 @@ class RendererWebGPU extends Renderer3D { new RegExp(`struct\\s+${structName}\\s*\\{([^\\}]+)\\}`) ); if (!structMatch) { - throw new Error(`Can't find a struct defnition for ${structName}`); + throw new Error(`Can't find a struct definition for ${structName}`); } const structBody = structMatch[1]; From f120ad95d83ed0bc007af275264e39a4caba7c14 Mon Sep 17 00:00:00 2001 From: lukeplowden Date: Mon, 16 Jun 2025 18:07:51 +0100 Subject: [PATCH 26/72] typo in string --- src/webgl/p5.RendererGL.js | 7 ------- src/webgpu/p5.RendererWebGPU.js | 2 +- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/src/webgl/p5.RendererGL.js b/src/webgl/p5.RendererGL.js index 676cb3b6e7..f683075df1 100644 --- a/src/webgl/p5.RendererGL.js +++ b/src/webgl/p5.RendererGL.js @@ -276,11 +276,7 @@ class RendererGL extends Renderer3D { if (!glBuffers) return; -<<<<<<< HEAD - if (this._curShader.shaderType === 'stroke') { -======= if (this._curShader.shaderType === 'stroke'){ ->>>>>>> f0875d795be9297bcdf9b312dffcafeda4741536 if (count === 1) { gl.drawArrays(gl.TRIANGLES, 0, geometry.lineVertices.length / 3); } else { @@ -296,10 +292,7 @@ class RendererGL extends Renderer3D { "🌸 p5.js says: Instancing is only supported in WebGL2 mode" ); } -<<<<<<< HEAD } -======= ->>>>>>> f0875d795be9297bcdf9b312dffcafeda4741536 } else if (glBuffers.indexBuffer) { this._bindBuffer(glBuffers.indexBuffer, gl.ELEMENT_ARRAY_BUFFER); diff --git a/src/webgpu/p5.RendererWebGPU.js b/src/webgpu/p5.RendererWebGPU.js index 5d023e266f..0b9dd6fcfa 100644 --- a/src/webgpu/p5.RendererWebGPU.js +++ b/src/webgpu/p5.RendererWebGPU.js @@ -675,7 +675,7 @@ class RendererWebGPU extends Renderer3D { new RegExp(`struct\\s+${structName}\\s*\\{([^\\}]+)\\}`) ); if (!structMatch) { - throw new Error(`Can't find a struct defnition for ${structName}`); + throw new Error(`Can't find a struct definition for ${structName}`); } const structBody = structMatch[1]; From 468b6384c0bc8e6cd3b64a7e52f52c210739e085 Mon Sep 17 00:00:00 2001 From: lukeplowden Date: Mon, 16 Jun 2025 18:15:18 +0100 Subject: [PATCH 27/72] multiply alpha after hook --- src/webgpu/shaders/line.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/webgpu/shaders/line.js b/src/webgpu/shaders/line.js index 0aa9f5e72b..5c01ddd1bf 100644 --- a/src/webgpu/shaders/line.js +++ b/src/webgpu/shaders/line.js @@ -351,7 +351,8 @@ fn main(input: StrokeFragmentInput) -> @location(0) vec4 { discard; } } - var col = HOOK_getFinalColor(vec4(inputs.color.rgb, 1.) * inputs.color.a); + var col = HOOK_getFinalColor(inputs.color); + col = vec4(col.rgb, 1.0) * col.a; HOOK_afterFragment(); return vec4(col); } From 01a7c1c69d8ecc6e6e21b0cf9b00f0f39a663cbe Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Tue, 17 Jun 2025 19:26:36 -0400 Subject: [PATCH 28/72] Fix some RendererGL + filterRenderer2D tests --- src/image/filterRenderer2D.js | 173 ++++++++++++++- src/shape/custom_shapes.js | 64 +++--- src/webgl/3d_primitives.js | 8 +- src/webgl/p5.Framebuffer.js | 20 +- src/webgl/p5.Geometry.js | 16 +- src/webgl/p5.RendererGL.js | 366 ++----------------------------- src/webgl/p5.Shader.js | 14 +- src/webgl/p5.Texture.js | 7 +- src/webgl/shaders/line.vert | 2 +- src/webgl/utils.js | 351 +++++++++++++++++++++++++++++ test/unit/webgl/p5.RendererGL.js | 3 +- vitest.workspace.mjs | 3 +- 12 files changed, 628 insertions(+), 399 deletions(-) diff --git a/src/image/filterRenderer2D.js b/src/image/filterRenderer2D.js index 97eed42671..cfdf10eb8d 100644 --- a/src/image/filterRenderer2D.js +++ b/src/image/filterRenderer2D.js @@ -1,6 +1,13 @@ import { Shader } from "../webgl/p5.Shader"; import { Texture } from "../webgl/p5.Texture"; import { Image } from "./p5.Image"; +import { + getWebGLShaderAttributes, + getWebGLUniformMetadata, + populateGLSLHooks, + setWebGLTextureParams, + setWebGLUniformValue +} from "../webgl/utils"; import * as constants from '../core/constants'; import filterGrayFrag from '../webgl/shaders/filters/gray.frag'; @@ -42,6 +49,9 @@ class FilterRenderer2D { console.error("WebGL not supported, cannot apply filter."); return; } + + this.textures = new Map(); + // Minimal renderer object required by p5.Shader and p5.Texture this._renderer = { GL: this.gl, @@ -62,6 +72,167 @@ class FilterRenderer2D { } return this._emptyTexture; }, + _initShader: (shader) => { + const gl = this.gl; + + const vertShader = gl.createShader(gl.VERTEX_SHADER); + gl.shaderSource(vertShader, shader.vertSrc()); + gl.compileShader(vertShader); + if (!gl.getShaderParameter(vertShader, gl.COMPILE_STATUS)) { + throw new Error(`Yikes! An error occurred compiling the vertex shader: ${ + gl.getShaderInfoLog(vertShader) + }`); + } + + const fragShader = gl.createShader(gl.FRAGMENT_SHADER); + gl.shaderSource(fragShader, shader.fragSrc()); + gl.compileShader(fragShader); + if (!gl.getShaderParameter(fragShader, gl.COMPILE_STATUS)) { + throw new Error(`Darn! An error occurred compiling the fragment shader: ${ + gl.getShaderInfoLog(fragShader) + }`); + } + + const program = gl.createProgram(); + gl.attachShader(program, vertShader); + gl.attachShader(program, fragShader); + gl.linkProgram(program); + + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + throw new Error( + `Snap! Error linking shader program: ${gl.getProgramInfoLog(program)}` + ); + } + + shader._glProgram = program; + shader._vertShader = vertShader; + shader._fragShader = fragShader; + }, + getTexture: (input) => { + let src = input; + if (src instanceof Framebuffer) { + src = src.color; + } + + const texture = this.textures.get(src); + if (texture) { + return texture; + } + + const tex = new Texture(this._renderer, src); + this.textures.set(src, tex); + return tex; + }, + populateHooks: (shader, src, shaderType) => { + return populateGLSLHooks(shader, src, shaderType); + }, + _getShaderAttributes: (shader) => { + return getWebGLShaderAttributes(shader, this.gl); + }, + getUniformMetadata: (shader) => { + return getWebGLUniformMetadata(shader, this.gl); + }, + _finalizeShader: () => {}, + _useShader: (shader) => { + this.gl.useProgram(shader._glProgram); + }, + bindTexture: (tex) => { + // bind texture using gl context + glTarget and + // generated gl texture object + this.gl.bindTexture(this.gl.TEXTURE_2D, tex.getTexture().texture); + }, + unbindTexture: () => { + // unbind per above, disable texturing on glTarget + this.gl.bindTexture(this.gl.TEXTURE_2D, null); + }, + _unbindFramebufferTexture: (uniform) => { + // Make sure an empty texture is bound to the slot so that we don't + // accidentally leave a framebuffer bound, causing a feedback loop + // when something else tries to write to it + const gl = this.gl; + const empty = this._getEmptyTexture(); + gl.activeTexture(gl.TEXTURE0 + uniform.samplerIndex); + empty.bindTexture(); + gl.uniform1i(uniform.location, uniform.samplerIndex); + }, + createTexture: ({ width, height, format, dataType }) => { + const gl = this.gl; + const tex = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_2D, tex); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, + gl.RGBA, gl.UNSIGNED_BYTE, null); + // TODO use format and data type + return { texture: tex, glFormat: gl.RGBA, glDataType: gl.UNSIGNED_BYTE }; + }, + uploadTextureFromSource: ({ texture, glFormat, glDataType }, source) => { + const gl = this.gl; + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.texImage2D(gl.TEXTURE_2D, 0, glFormat, glFormat, glDataType, source); + }, + uploadTextureFromData: ({ texture, glFormat, glDataType }, data, width, height) => { + const gl = this.gl; + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.texImage2D( + gl.TEXTURE_2D, + 0, + glFormat, + width, + height, + 0, + glFormat, + glDataType, + data + ); + }, + setTextureParams: (texture) => { + return setWebGLTextureParams(texture, this.gl, this._renderer.webglVersion); + }, + updateUniformValue: (shader, uniform, data) => { + return setWebGLUniformValue( + shader, + uniform, + data, + (tex) => this._renderer.getTexture(tex), + this.gl + ); + }, + _enableAttrib: (_shader, attr, size, type, normalized, stride, offset) => { + const loc = attr.location; + const gl = this.gl; + // Enable register even if it is disabled + if (!this._renderer.registerEnabled.has(loc)) { + gl.enableVertexAttribArray(loc); + // Record register availability + this._renderer.registerEnabled.add(loc); + } + gl.vertexAttribPointer( + loc, + size, + type || gl.FLOAT, + normalized || false, + stride || 0, + offset || 0 + ); + }, + _disableRemainingAttributes: (shader) => { + for (const location of this._renderer.registerEnabled.values()) { + if ( + !Object.keys(shader.attributes).some( + key => shader.attributes[key].location === location + ) + ) { + this.gl.disableVertexAttribArray(location); + this._renderer.registerEnabled.delete(location); + } + } + }, + _updateTexture: (uniform, tex) => { + const gl = this.gl; + gl.activeTexture(gl.TEXTURE0 + uniform.samplerIndex); + tex.bindTexture(); + tex.update(); + gl.uniform1i(uniform.location, uniform.samplerIndex); + } }; this._baseFilterShader = undefined; @@ -257,7 +428,7 @@ class FilterRenderer2D { this._shader.enableAttrib(this._shader.attributes.aTexCoord, 2); this._shader.bindTextures(); - this._shader.disableRemainingAttributes(); + this._renderer._disableRemainingAttributes(this._shader); // Draw the quad gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); diff --git a/src/shape/custom_shapes.js b/src/shape/custom_shapes.js index 9e22f0b75c..3049094125 100644 --- a/src/shape/custom_shapes.js +++ b/src/shape/custom_shapes.js @@ -1170,10 +1170,12 @@ class PrimitiveToPath2DConverter extends PrimitiveVisitor { class PrimitiveToVerticesConverter extends PrimitiveVisitor { contours = []; curveDetail; + pointsToLines; - constructor({ curveDetail = 1 } = {}) { + constructor({ curveDetail = 1, pointsToLines = true } = {}) { super(); this.curveDetail = curveDetail; + this.pointsToLines = pointsToLines; } lastContour() { @@ -1246,7 +1248,11 @@ class PrimitiveToVerticesConverter extends PrimitiveVisitor { } } visitPoint(point) { - this.contours.push(point.vertices.slice()); + if (this.pointsToLines) { + this.contours.push(...point.vertices.map(v => [v, v])); + } else { + this.contours.push(point.vertices.slice()); + } } visitLine(line) { this.contours.push(line.vertices.slice()); @@ -1592,11 +1598,11 @@ function customShapes(p5, fn) { * one call to bezierVertex(), before * a number of `bezierVertex()` calls that is a multiple of the parameter * set by bezierOrder(...) (default 3). - * + * * Each curve of order 3 requires three calls to `bezierVertex`, so * 2 curves would need 7 calls to `bezierVertex()`: * (1 one initial anchor point, two sets of 3 curves describing the curves) - * With `bezierOrder(2)`, two curves would need 5 calls: 1 + 2 + 2. + * With `bezierOrder(2)`, two curves would need 5 calls: 1 + 2 + 2. * * Bézier curves can also be drawn in 3D using WebGL mode. * @@ -1605,7 +1611,7 @@ function customShapes(p5, fn) { * * @method bezierOrder * @param {Number} order The new order to set. Can be either 2 or 3, by default 3 - * + * * @example *
* @@ -1619,7 +1625,7 @@ function customShapes(p5, fn) { * * // Start drawing the shape. * beginShape(); - * + * * // set the order to 2 for a quadratic Bézier curve * bezierOrder(2); * @@ -2059,11 +2065,11 @@ function customShapes(p5, fn) { /** * Sets the property of a curve. - * + * * For example, set tightness, * use `splineProperty('tightness', t)`, with `t` between 0 and 1, * at 0 as default. - * + * * Spline curves are like cables that are attached to a set of points. * Adjusting tightness adjusts how tightly the cable is * attached to the points. The parameter, tightness, determines @@ -2072,33 +2078,33 @@ function customShapes(p5, fn) { * `splineProperty('tightness', 1)`, connects the curve's points * using straight lines. Values in the range from –5 to 5 * deform curves while leaving them recognizable. - * + * * This function can also be used to set 'ends' property * (see also: the curveDetail() example), * such as: `splineProperty('ends', EXCLUDE)` to exclude * vertices, or `splineProperty('ends', INCLUDE)` to include them. - * + * * @method splineProperty * @param {String} property * @param value Value to set the given property to. - * + * * @example *
* * // Move the mouse left and right to see the curve change. - * + * * function setup() { * createCanvas(100, 100); * describe('A black curve forms a sideways U shape. The curve deforms as the user moves the mouse from left to right'); * } - * + * * function draw() { * background(200); - * + * * // Set the curve's tightness using the mouse. * let t = map(mouseX, 0, 100, -5, 5, true); * splineProperty('tightness', t); - * + * * // Draw the curve. * noFill(); * beginShape(); @@ -2124,11 +2130,11 @@ function customShapes(p5, fn) { /** * Get or set multiple spline properties at once. - * + * * Similar to splineProperty(): * `splineProperty('tightness', t)` is the same as * `splineProperties({'tightness': t})` - * + * * @method splineProperties * @param {Object} properties An object containing key-value pairs to set. */ @@ -2307,7 +2313,7 @@ function customShapes(p5, fn) { * } * *
- * + * *
* * let vid; @@ -2315,28 +2321,28 @@ function customShapes(p5, fn) { * // Load a video and create a p5.MediaElement object. * vid = createVideo('/assets/fingers.mov'); * createCanvas(100, 100, WEBGL); - * + * * // Hide the video. * vid.hide(); - * + * * // Set the video to loop. * vid.loop(); - * + * * describe('A rectangle with video as texture'); * } - * + * * function draw() { * background(0); - * + * * // Rotate around the y-axis. * rotateY(frameCount * 0.01); - * + * * // Set the texture mode. * textureMode(NORMAL); - * + * * // Apply the video as a texture. * texture(vid); - * + * * // Draw a custom shape using uv coordinates. * beginShape(); * vertex(-40, -40, 0, 0); @@ -2489,7 +2495,7 @@ function customShapes(p5, fn) { }; /** - * Stops creating a hole within a flat shape. + * Stops creating a hole within a flat shape. * * The beginContour() and `endContour()` * functions allow for creating negative space within custom shapes that are @@ -2499,10 +2505,10 @@ function customShapes(p5, fn) { * called between beginShape() and * endShape(). * - * By default, + * By default, * the controur has an `OPEN` end, and to close it, * call `endContour(CLOSE)`. The CLOSE contour mode closes splines smoothly. - * + * * Transformations such as translate(), * rotate(), and scale() * don't work between beginContour() and diff --git a/src/webgl/3d_primitives.js b/src/webgl/3d_primitives.js index 38c0b426e2..8c29e3ea2d 100644 --- a/src/webgl/3d_primitives.js +++ b/src/webgl/3d_primitives.js @@ -1667,11 +1667,9 @@ function primitives3D(p5, fn){ *
*/ Renderer3D.prototype.point = function(x, y, z = 0) { - - const _vertex = []; - _vertex.push(new Vector(x, y, z)); - // TODO - // this._drawPoints(_vertex, this.buffers.point); + this.beginShape(constants.POINTS); + this.vertex(x, y, z); + this.endShape(); return this; }; diff --git a/src/webgl/p5.Framebuffer.js b/src/webgl/p5.Framebuffer.js index d04d14839f..0ebb3c0daa 100644 --- a/src/webgl/p5.Framebuffer.js +++ b/src/webgl/p5.Framebuffer.js @@ -52,7 +52,8 @@ class FramebufferTexture { } rawTexture() { - return this.framebuffer[this.property]; + // TODO: handle webgpu texture handle + return { texture: this.framebuffer[this.property] }; } } @@ -586,7 +587,7 @@ class Framebuffer { if (this.useDepth) { this.depth = new FramebufferTexture(this, 'depthTexture'); - const depthFilter = gl.NEAREST; + const depthFilter = constants.NEAREST; this.depthP5Texture = new Texture( this.renderer, this.depth, @@ -600,8 +601,8 @@ class Framebuffer { this.color = new FramebufferTexture(this, 'colorTexture'); const filter = this.textureFiltering === constants.LINEAR - ? gl.LINEAR - : gl.NEAREST; + ? constants.LINEAR + : constants.NEAREST; this.colorP5Texture = new Texture( this.renderer, this.color, @@ -921,7 +922,7 @@ class Framebuffer { */ _deleteTexture(texture) { const gl = this.gl; - gl.deleteTexture(texture.rawTexture()); + gl.deleteTexture(texture.rawTexture().texture); this.renderer.textures.delete(texture); } @@ -1115,12 +1116,17 @@ class Framebuffer { gl.bindFramebuffer(gl.READ_FRAMEBUFFER, this.aaFramebuffer); gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, this.framebuffer); const partsToCopy = { - colorTexture: [gl.COLOR_BUFFER_BIT, this.colorP5Texture.glMagFilter], + colorTexture: [ + gl.COLOR_BUFFER_BIT, + // TODO: move to renderer + this.colorP5Texture.magFilter === constants.LINEAR ? gl.LINEAR : gl.NEAREST + ], }; if (this.useDepth) { partsToCopy.depthTexture = [ gl.DEPTH_BUFFER_BIT, - this.depthP5Texture.glMagFilter + // TODO: move to renderer + this.depthP5Texture.magFilter === constants.LINEAR ? gl.LINEAR : gl.NEAREST ]; } const [flag, filter] = partsToCopy[property]; diff --git a/src/webgl/p5.Geometry.js b/src/webgl/p5.Geometry.js index 22e3a481c4..b74fe4c827 100644 --- a/src/webgl/p5.Geometry.js +++ b/src/webgl/p5.Geometry.js @@ -1419,6 +1419,7 @@ class Geometry { for (let i = 0; i < this.edges.length; i++) { const prevEdge = this.edges[i - 1]; const currEdge = this.edges[i]; + const isPoint = currEdge[0] === currEdge[1]; const begin = this.vertices[currEdge[0]]; const end = this.vertices[currEdge[1]]; const prevColor = (this.vertexStrokeColors.length > 0 && prevEdge) @@ -1439,10 +1440,12 @@ class Geometry { (currEdge[1] + 1) * 4 ) : [0, 0, 0, 0]; - const dir = end - .copy() - .sub(begin) - .normalize(); + const dir = isPoint + ? new Vector(0, 1, 0) + : end + .copy() + .sub(begin) + .normalize(); const dirOK = dir.magSq() > 0; if (dirOK) { this._addSegment(begin, end, fromColor, toColor, dir); @@ -1462,6 +1465,9 @@ class Geometry { this._addJoin(begin, lastValidDir, dir, fromColor); } } + } else if (isPoint) { + this._addCap(begin, dir.copy().mult(-1), fromColor); + this._addCap(begin, dir, fromColor); } else { // Start a new line if (dirOK && !connected.has(currEdge[0])) { @@ -1483,7 +1489,7 @@ class Geometry { }); } } - if (lastValidDir && !connected.has(prevEdge[1])) { + if (!isPoint && lastValidDir && !connected.has(prevEdge[1])) { const existingCap = potentialCaps.get(prevEdge[1]); if (existingCap) { this._addJoin( diff --git a/src/webgl/p5.RendererGL.js b/src/webgl/p5.RendererGL.js index f683075df1..c0d9ef244a 100644 --- a/src/webgl/p5.RendererGL.js +++ b/src/webgl/p5.RendererGL.js @@ -1,5 +1,13 @@ import * as constants from "../core/constants"; -import { readPixelsWebGL, readPixelWebGL } from './utils'; +import { + getWebGLShaderAttributes, + getWebGLUniformMetadata, + populateGLSLHooks, + readPixelsWebGL, + readPixelWebGL, + setWebGLTextureParams, + setWebGLUniformValue +} from './utils'; import { Renderer3D, getStrokeDefs } from "../core/p5.Renderer3D"; import { Shader } from "./p5.Shader"; import { Texture, MipmapTexture } from "./p5.Texture"; @@ -39,7 +47,7 @@ import filterInvertFrag from "./shaders/filters/invert.frag"; import filterThresholdFrag from "./shaders/filters/threshold.frag"; import filterShaderVert from "./shaders/filters/default.vert"; -const { lineDefs } = getStrokeDefs((n, v) => `#define ${n} ${v};\n`); +const { lineDefs } = getStrokeDefs((n, v) => `#define ${n} ${v}\n`); const defaultShaders = { normalVert, @@ -1219,7 +1227,7 @@ class RendererGL extends Renderer3D { if (!gl.getShaderParameter(vertShader, gl.COMPILE_STATUS)) { throw new Error(`Yikes! An error occurred compiling the vertex shader: ${ gl.getShaderInfoLog(vertShader) - }`); + } in:\n\n${shader.vertSrc()}`); } const fragShader = gl.createShader(gl.FRAGMENT_SHADER); @@ -1250,216 +1258,21 @@ class RendererGL extends Renderer3D { _finalizeShader() {} _getShaderAttributes(shader) { - const attributes = {}; - - const gl = this.GL; - - const numAttributes = gl.getProgramParameter( - shader._glProgram, - gl.ACTIVE_ATTRIBUTES - ); - for (let i = 0; i < numAttributes; ++i) { - const attributeInfo = gl.getActiveAttrib(shader._glProgram, i); - const name = attributeInfo.name; - const location = gl.getAttribLocation(shader._glProgram, name); - const attribute = {}; - attribute.name = name; - attribute.location = location; - attribute.index = i; - attribute.type = attributeInfo.type; - attribute.size = attributeInfo.size; - attributes[name] = attribute; - } - - return attributes; + return getWebGLShaderAttributes(shader, this.GL); } getUniformMetadata(shader) { - const gl = this.GL; - const program = shader._glProgram; - - const numUniforms = gl.getProgramParameter(program, gl.ACTIVE_UNIFORMS); - const result = []; - - let samplerIndex = 0; - - for (let i = 0; i < numUniforms; ++i) { - const uniformInfo = gl.getActiveUniform(program, i); - const uniform = {}; - uniform.location = gl.getUniformLocation( - program, - uniformInfo.name - ); - uniform.size = uniformInfo.size; - let uniformName = uniformInfo.name; - //uniforms that are arrays have their name returned as - //someUniform[0] which is a bit silly so we trim it - //off here. The size property tells us that its an array - //so we dont lose any information by doing this - if (uniformInfo.size > 1) { - uniformName = uniformName.substring(0, uniformName.indexOf('[0]')); - } - uniform.name = uniformName; - uniform.type = uniformInfo.type; - uniform._cachedData = undefined; - if (uniform.type === gl.SAMPLER_2D) { - uniform.isSampler = true; - uniform.samplerIndex = samplerIndex; - samplerIndex++; - } - - uniform.isArray = - uniformInfo.size > 1 || - uniform.type === gl.FLOAT_MAT3 || - uniform.type === gl.FLOAT_MAT4 || - uniform.type === gl.FLOAT_VEC2 || - uniform.type === gl.FLOAT_VEC3 || - uniform.type === gl.FLOAT_VEC4 || - uniform.type === gl.INT_VEC2 || - uniform.type === gl.INT_VEC4 || - uniform.type === gl.INT_VEC3; - - result.push(uniform); - } - - return result; + return getWebGLUniformMetadata(shader, this.GL); } updateUniformValue(shader, uniform, data) { - const gl = this.GL; - const location = uniform.location; - shader.useProgram(); - - switch (uniform.type) { - case gl.BOOL: - if (data === true) { - gl.uniform1i(location, 1); - } else { - gl.uniform1i(location, 0); - } - break; - case gl.INT: - if (uniform.size > 1) { - data.length && gl.uniform1iv(location, data); - } else { - gl.uniform1i(location, data); - } - break; - case gl.FLOAT: - if (uniform.size > 1) { - data.length && gl.uniform1fv(location, data); - } else { - gl.uniform1f(location, data); - } - break; - case gl.FLOAT_MAT3: - gl.uniformMatrix3fv(location, false, data); - break; - case gl.FLOAT_MAT4: - gl.uniformMatrix4fv(location, false, data); - break; - case gl.FLOAT_VEC2: - if (uniform.size > 1) { - data.length && gl.uniform2fv(location, data); - } else { - gl.uniform2f(location, data[0], data[1]); - } - break; - case gl.FLOAT_VEC3: - if (uniform.size > 1) { - data.length && gl.uniform3fv(location, data); - } else { - gl.uniform3f(location, data[0], data[1], data[2]); - } - break; - case gl.FLOAT_VEC4: - if (uniform.size > 1) { - data.length && gl.uniform4fv(location, data); - } else { - gl.uniform4f(location, data[0], data[1], data[2], data[3]); - } - break; - case gl.INT_VEC2: - if (uniform.size > 1) { - data.length && gl.uniform2iv(location, data); - } else { - gl.uniform2i(location, data[0], data[1]); - } - break; - case gl.INT_VEC3: - if (uniform.size > 1) { - data.length && gl.uniform3iv(location, data); - } else { - gl.uniform3i(location, data[0], data[1], data[2]); - } - break; - case gl.INT_VEC4: - if (uniform.size > 1) { - data.length && gl.uniform4iv(location, data); - } else { - gl.uniform4i(location, data[0], data[1], data[2], data[3]); - } - break; - case gl.SAMPLER_2D: - if (typeof data == 'number') { - if ( - data < gl.TEXTURE0 || - data > gl.TEXTURE31 || - data !== Math.ceil(data) - ) { - console.log( - '🌸 p5.js says: ' + - "You're trying to use a number as the data for a texture." + - 'Please use a texture.' - ); - return this; - } - gl.activeTexture(data); - gl.uniform1i(location, data); - } else { - gl.activeTexture(gl.TEXTURE0 + uniform.samplerIndex); - uniform.texture = - data instanceof Texture ? data : this._renderer.getTexture(data); - gl.uniform1i(location, uniform.samplerIndex); - if (uniform.texture.src.gifProperties) { - uniform.texture.src._animateGif(this._renderer._pInst); - } - } - break; - case gl.SAMPLER_CUBE: - case gl.SAMPLER_3D: - case gl.SAMPLER_2D_SHADOW: - case gl.SAMPLER_2D_ARRAY: - case gl.SAMPLER_2D_ARRAY_SHADOW: - case gl.SAMPLER_CUBE_SHADOW: - case gl.INT_SAMPLER_2D: - case gl.INT_SAMPLER_3D: - case gl.INT_SAMPLER_CUBE: - case gl.INT_SAMPLER_2D_ARRAY: - case gl.UNSIGNED_INT_SAMPLER_2D: - case gl.UNSIGNED_INT_SAMPLER_3D: - case gl.UNSIGNED_INT_SAMPLER_CUBE: - case gl.UNSIGNED_INT_SAMPLER_2D_ARRAY: - if (typeof data !== 'number') { - break; - } - if ( - data < gl.TEXTURE0 || - data > gl.TEXTURE31 || - data !== Math.ceil(data) - ) { - console.log( - '🌸 p5.js says: ' + - "You're trying to use a number as the data for a texture." + - 'Please use a texture.' - ); - break; - } - gl.activeTexture(data); - gl.uniform1i(location, data); - break; - //@todo complete all types - } + return setWebGLUniformValue( + shader, + uniform, + data, + (tex) => this.getTexture(tex), + this.GL + ); } _updateTexture(uniform, tex) { @@ -1473,7 +1286,7 @@ class RendererGL extends Renderer3D { bindTexture(tex) { // bind texture using gl context + glTarget and // generated gl texture object - this.GL.bindTexture(this.GL.TEXTURE_2D, tex.getTexture()); + this.GL.bindTexture(this.GL.TEXTURE_2D, tex.getTexture().texture); } unbindTexture() { @@ -1486,7 +1299,7 @@ class RendererGL extends Renderer3D { // accidentally leave a framebuffer bound, causing a feedback loop // when something else tries to write to it const gl = this.GL; - const empty = this._renderer._getEmptyTexture(); + const empty = this._getEmptyTexture(); gl.activeTexture(gl.TEXTURE0 + uniform.samplerIndex); empty.bindTexture(); gl.uniform1i(uniform.location, uniform.samplerIndex); @@ -1504,13 +1317,11 @@ class RendererGL extends Renderer3D { uploadTextureFromSource({ texture, glFormat, glDataType }, source) { const gl = this.GL; - gl.bindTexture(gl.TEXTURE_2D, texture); gl.texImage2D(gl.TEXTURE_2D, 0, glFormat, glFormat, glDataType, source); } uploadTextureFromData({ texture, glFormat, glDataType }, data, width, height) { const gl = this.GL; - gl.bindTexture(gl.TEXTURE_2D, texture); gl.texImage2D( gl.TEXTURE_2D, 0, @@ -1537,95 +1348,7 @@ class RendererGL extends Renderer3D { } setTextureParams(texture) { - const gl = this.GL; - texture.bindTexture(); - const glMinFilter = texture.minFilter === constants.NEAREST ? gl.NEAREST : gl.LINEAR; - const glMagFilter = texture.magFilter === constants.NEAREST ? gl.NEAREST : gl.LINEAR; - - // for webgl 1 we need to check if the texture is power of two - // if it isn't we will set the wrap mode to CLAMP - // webgl2 will support npot REPEAT and MIRROR but we don't check for it yet - const isPowerOfTwo = x => (x & (x - 1)) === 0; - const textureData = texture._getTextureDataFromSource(); - - let wrapWidth; - let wrapHeight; - - if (textureData.naturalWidth && textureData.naturalHeight) { - wrapWidth = textureData.naturalWidth; - wrapHeight = textureData.naturalHeight; - } else { - wrapWidth = this.width; - wrapHeight = this.height; - } - - const widthPowerOfTwo = isPowerOfTwo(wrapWidth); - const heightPowerOfTwo = isPowerOfTwo(wrapHeight); - let glWrapS, glWrapT; - - if (texture.wrapS === constants.REPEAT) { - if ( - this.webglVersion === constants.WEBGL2 || - (widthPowerOfTwo && heightPowerOfTwo) - ) { - glWrapS = gl.REPEAT; - } else { - console.warn( - 'You tried to set the wrap mode to REPEAT but the texture size is not a power of two. Setting to CLAMP instead' - ); - glWrapS = gl.CLAMP_TO_EDGE; - } - } else if (texture.wrapS === constants.MIRROR) { - if ( - this.webglVersion === constants.WEBGL2 || - (widthPowerOfTwo && heightPowerOfTwo) - ) { - glWrapS = gl.MIRRORED_REPEAT; - } else { - console.warn( - 'You tried to set the wrap mode to MIRROR but the texture size is not a power of two. Setting to CLAMP instead' - ); - glWrapS = gl.CLAMP_TO_EDGE; - } - } else { - // falling back to default if didn't get a proper mode - glWrapS = gl.CLAMP_TO_EDGE; - } - - if (texture.wrapT === constants.REPEAT) { - if ( - this._renderer.webglVersion === constants.WEBGL2 || - (widthPowerOfTwo && heightPowerOfTwo) - ) { - glWrapT = gl.REPEAT; - } else { - console.warn( - 'You tried to set the wrap mode to REPEAT but the texture size is not a power of two. Setting to CLAMP instead' - ); - glWrapT = gl.CLAMP_TO_EDGE; - } - } else if (texture.wrapT === constants.MIRROR) { - if ( - this._renderer.webglVersion === constants.WEBGL2 || - (widthPowerOfTwo && heightPowerOfTwo) - ) { - glWrapT = gl.MIRRORED_REPEAT; - } else { - console.warn( - 'You tried to set the wrap mode to MIRROR but the texture size is not a power of two. Setting to CLAMP instead' - ); - glWrapT = gl.CLAMP_TO_EDGE; - } - } else { - // falling back to default if didn't get a proper mode - glWrapT = gl.CLAMP_TO_EDGE; - } - - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, glMinFilter); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, glMagFilter); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, glWrapS); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, glWrapT); - texture.unbindTexture(); + return setWebGLTextureParams(texture, this.GL, this.webglVersion); } deleteTexture({ texture }) { @@ -1677,48 +1400,7 @@ class RendererGL extends Renderer3D { // Shader hooks ////////////////////////////////////////////// populateHooks(shader, src, shaderType) { - const main = 'void main'; - if (!src.includes(main)) return src; - - let [preMain, postMain] = src.split(main); - - let hooks = ''; - let defines = ''; - for (const key in shader.hooks.uniforms) { - hooks += `uniform ${key};\n`; - } - if (shader.hooks.declarations) { - hooks += shader.hooks.declarations + '\n'; - } - if (shader.hooks[shaderType].declarations) { - hooks += shader.hooks[shaderType].declarations + '\n'; - } - for (const hookDef in shader.hooks.helpers) { - hooks += `${hookDef}${shader.hooks.helpers[hookDef]}\n`; - } - for (const hookDef in shader.hooks[shaderType]) { - if (hookDef === 'declarations') continue; - const [hookType, hookName] = hookDef.split(' '); - - // Add a #define so that if the shader wants to use preprocessor directives to - // optimize away the extra function calls in main, it can do so - if (shader.hooks.modified[shaderType][hookDef]) { - defines += '#define AUGMENTED_HOOK_' + hookName + '\n'; - } - - hooks += - hookType + ' HOOK_' + hookName + shader.hooks[shaderType][hookDef] + '\n'; - } - - // Allow shaders to specify the location of hook #define statements. Normally these - // go after function definitions, but one might want to have them defined earlier - // in order to only conditionally make uniforms. - if (preMain.indexOf('#define HOOK_DEFINES') !== -1) { - preMain = preMain.replace('#define HOOK_DEFINES', '\n' + defines + '\n'); - defines = ''; - } - - return preMain + '\n' + defines + hooks + main + postMain; + return populateGLSLHooks(shader, src, shaderType); } } diff --git a/src/webgl/p5.Shader.js b/src/webgl/p5.Shader.js index 0134701c9c..9a506a2801 100644 --- a/src/webgl/p5.Shader.js +++ b/src/webgl/p5.Shader.js @@ -709,7 +709,17 @@ class Shader { for (const uniform of this.samplers) { let tex = uniform.texture; - if (tex === undefined) { + if ( + tex === undefined || + ( + // Make sure we unbind a framebuffer uniform if it's the same + // framebuffer that is actvely being drawn to in order to + // prevent a feedback cycle + tex.isFramebufferTexture && + !tex.src.framebuffer.antialias && + tex.src.framebuffer === this._renderer.activeFramebuffer() + ) + ) { // user hasn't yet supplied a texture for this slot. // (or there may not be one--maybe just lighting), // so we supply a default texture instead. @@ -1026,7 +1036,7 @@ class Shader { } if (attr.location !== -1) { - this._renderer._enableAttrib(attr, size, type, normalized, stride, offset); + this._renderer._enableAttrib(this, attr, size, type, normalized, stride, offset); } } return this; diff --git a/src/webgl/p5.Texture.js b/src/webgl/p5.Texture.js index b9519cb606..e7103bd13c 100644 --- a/src/webgl/p5.Texture.js +++ b/src/webgl/p5.Texture.js @@ -130,7 +130,7 @@ class Texture { }); } - this._renderer.setTextureParams(this.textureHandle, { + this._renderer.setTextureParams(this, { minFilter: this.minFilter, magFilter: this.magFilter, wrapS: this.wrapS, @@ -179,14 +179,13 @@ class Texture { if (this._shouldUpdate(textureData)) { this.bindTexture(); this._renderer.uploadTextureFromSource(this.textureHandle, textureData); - this.unbindTexture(); updated = true; } return updated; } - shouldUpdate(textureData) { + _shouldUpdate(textureData) { const data = this.src; if (data.width === 0 || data.height === 0) { return false; // nothing to do! @@ -280,7 +279,7 @@ class Texture { if (this.isFramebufferTexture) { return this.src.rawTexture(); } else { - return this.glTex; + return this.textureHandle; } } diff --git a/src/webgl/shaders/line.vert b/src/webgl/shaders/line.vert index a00bf94ba8..65cd9502c6 100644 --- a/src/webgl/shaders/line.vert +++ b/src/webgl/shaders/line.vert @@ -127,7 +127,7 @@ void main() { inputs.tangentOut = (uModelViewMatrix * vec4(aTangentOut, 0.)).xyz; #endif #ifdef AUGMENTED_HOOK_getCameraInputs - inputs = hook_getCameraInputs(inputs); + inputs = HOOK_getCameraInputs(inputs); #endif vec4 posp = vec4(inputs.position, 1.); diff --git a/src/webgl/utils.js b/src/webgl/utils.js index b891e96d0b..70766ac522 100644 --- a/src/webgl/utils.js +++ b/src/webgl/utils.js @@ -1,3 +1,6 @@ +import * as constants from '../core/constants'; +import { Texture } from './p5.Texture'; + /** * @private * @param {Uint8Array|Float32Array|undefined} pixels An existing pixels array to reuse if the size is the same @@ -97,3 +100,351 @@ export function readPixelWebGL(gl, framebuffer, x, y, format, type, flipY) { return Array.from(pixels); } + +export function setWebGLTextureParams(texture, gl, webglVersion) { + texture.bindTexture(); + const glMinFilter = texture.minFilter === constants.NEAREST ? gl.NEAREST : gl.LINEAR; + const glMagFilter = texture.magFilter === constants.NEAREST ? gl.NEAREST : gl.LINEAR; + + // for webgl 1 we need to check if the texture is power of two + // if it isn't we will set the wrap mode to CLAMP + // webgl2 will support npot REPEAT and MIRROR but we don't check for it yet + const isPowerOfTwo = x => (x & (x - 1)) === 0; + const textureData = texture._getTextureDataFromSource(); + + let wrapWidth; + let wrapHeight; + + if (textureData.naturalWidth && textureData.naturalHeight) { + wrapWidth = textureData.naturalWidth; + wrapHeight = textureData.naturalHeight; + } else { + wrapWidth = texture.width; + wrapHeight = texture.height; + } + + const widthPowerOfTwo = isPowerOfTwo(wrapWidth); + const heightPowerOfTwo = isPowerOfTwo(wrapHeight); + let glWrapS, glWrapT; + + if (texture.wrapS === constants.REPEAT) { + if ( + webglVersion === constants.WEBGL2 || + (widthPowerOfTwo && heightPowerOfTwo) + ) { + glWrapS = gl.REPEAT; + } else { + console.warn( + 'You tried to set the wrap mode to REPEAT but the texture size is not a power of two. Setting to CLAMP instead' + ); + glWrapS = gl.CLAMP_TO_EDGE; + } + } else if (texture.wrapS === constants.MIRROR) { + if ( + webglVersion === constants.WEBGL2 || + (widthPowerOfTwo && heightPowerOfTwo) + ) { + glWrapS = gl.MIRRORED_REPEAT; + } else { + console.warn( + 'You tried to set the wrap mode to MIRROR but the texture size is not a power of two. Setting to CLAMP instead' + ); + glWrapS = gl.CLAMP_TO_EDGE; + } + } else { + // falling back to default if didn't get a proper mode + glWrapS = gl.CLAMP_TO_EDGE; + } + + if (texture.wrapT === constants.REPEAT) { + if ( + webglVersion === constants.WEBGL2 || + (widthPowerOfTwo && heightPowerOfTwo) + ) { + glWrapT = gl.REPEAT; + } else { + console.warn( + 'You tried to set the wrap mode to REPEAT but the texture size is not a power of two. Setting to CLAMP instead' + ); + glWrapT = gl.CLAMP_TO_EDGE; + } + } else if (texture.wrapT === constants.MIRROR) { + if ( + webglVersion === constants.WEBGL2 || + (widthPowerOfTwo && heightPowerOfTwo) + ) { + glWrapT = gl.MIRRORED_REPEAT; + } else { + console.warn( + 'You tried to set the wrap mode to MIRROR but the texture size is not a power of two. Setting to CLAMP instead' + ); + glWrapT = gl.CLAMP_TO_EDGE; + } + } else { + // falling back to default if didn't get a proper mode + glWrapT = gl.CLAMP_TO_EDGE; + } + + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, glMinFilter); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, glMagFilter); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, glWrapS); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, glWrapT); + texture.unbindTexture(); +} + +export function setWebGLUniformValue(shader, uniform, data, getTexture, gl) { + const location = uniform.location; + shader.useProgram(); + + switch (uniform.type) { + case gl.BOOL: + if (data === true) { + gl.uniform1i(location, 1); + } else { + gl.uniform1i(location, 0); + } + break; + case gl.INT: + if (uniform.size > 1) { + data.length && gl.uniform1iv(location, data); + } else { + gl.uniform1i(location, data); + } + break; + case gl.FLOAT: + if (uniform.size > 1) { + data.length && gl.uniform1fv(location, data); + } else { + gl.uniform1f(location, data); + } + break; + case gl.FLOAT_MAT3: + gl.uniformMatrix3fv(location, false, data); + break; + case gl.FLOAT_MAT4: + gl.uniformMatrix4fv(location, false, data); + break; + case gl.FLOAT_VEC2: + if (uniform.size > 1) { + data.length && gl.uniform2fv(location, data); + } else { + gl.uniform2f(location, data[0], data[1]); + } + break; + case gl.FLOAT_VEC3: + if (uniform.size > 1) { + data.length && gl.uniform3fv(location, data); + } else { + gl.uniform3f(location, data[0], data[1], data[2]); + } + break; + case gl.FLOAT_VEC4: + if (uniform.size > 1) { + data.length && gl.uniform4fv(location, data); + } else { + gl.uniform4f(location, data[0], data[1], data[2], data[3]); + } + break; + case gl.INT_VEC2: + if (uniform.size > 1) { + data.length && gl.uniform2iv(location, data); + } else { + gl.uniform2i(location, data[0], data[1]); + } + break; + case gl.INT_VEC3: + if (uniform.size > 1) { + data.length && gl.uniform3iv(location, data); + } else { + gl.uniform3i(location, data[0], data[1], data[2]); + } + break; + case gl.INT_VEC4: + if (uniform.size > 1) { + data.length && gl.uniform4iv(location, data); + } else { + gl.uniform4i(location, data[0], data[1], data[2], data[3]); + } + break; + case gl.SAMPLER_2D: + if (typeof data == 'number') { + if ( + data < gl.TEXTURE0 || + data > gl.TEXTURE31 || + data !== Math.ceil(data) + ) { + console.log( + '🌸 p5.js says: ' + + "You're trying to use a number as the data for a texture." + + 'Please use a texture.' + ); + return this; + } + gl.activeTexture(data); + gl.uniform1i(location, data); + } else { + gl.activeTexture(gl.TEXTURE0 + uniform.samplerIndex); + uniform.texture = + data instanceof Texture ? data : getTexture(data); + gl.uniform1i(location, uniform.samplerIndex); + if (uniform.texture.src.gifProperties) { + uniform.texture.src._animateGif(this._pInst); + } + } + break; + case gl.SAMPLER_CUBE: + case gl.SAMPLER_3D: + case gl.SAMPLER_2D_SHADOW: + case gl.SAMPLER_2D_ARRAY: + case gl.SAMPLER_2D_ARRAY_SHADOW: + case gl.SAMPLER_CUBE_SHADOW: + case gl.INT_SAMPLER_2D: + case gl.INT_SAMPLER_3D: + case gl.INT_SAMPLER_CUBE: + case gl.INT_SAMPLER_2D_ARRAY: + case gl.UNSIGNED_INT_SAMPLER_2D: + case gl.UNSIGNED_INT_SAMPLER_3D: + case gl.UNSIGNED_INT_SAMPLER_CUBE: + case gl.UNSIGNED_INT_SAMPLER_2D_ARRAY: + if (typeof data !== 'number') { + break; + } + if ( + data < gl.TEXTURE0 || + data > gl.TEXTURE31 || + data !== Math.ceil(data) + ) { + console.log( + '🌸 p5.js says: ' + + "You're trying to use a number as the data for a texture." + + 'Please use a texture.' + ); + break; + } + gl.activeTexture(data); + gl.uniform1i(location, data); + break; + //@todo complete all types + } +} + +export function getWebGLUniformMetadata(shader, gl) { + const program = shader._glProgram; + + const numUniforms = gl.getProgramParameter(program, gl.ACTIVE_UNIFORMS); + const result = []; + + let samplerIndex = 0; + + for (let i = 0; i < numUniforms; ++i) { + const uniformInfo = gl.getActiveUniform(program, i); + const uniform = {}; + uniform.location = gl.getUniformLocation( + program, + uniformInfo.name + ); + uniform.size = uniformInfo.size; + let uniformName = uniformInfo.name; + //uniforms that are arrays have their name returned as + //someUniform[0] which is a bit silly so we trim it + //off here. The size property tells us that its an array + //so we dont lose any information by doing this + if (uniformInfo.size > 1) { + uniformName = uniformName.substring(0, uniformName.indexOf('[0]')); + } + uniform.name = uniformName; + uniform.type = uniformInfo.type; + uniform._cachedData = undefined; + if (uniform.type === gl.SAMPLER_2D) { + uniform.isSampler = true; + uniform.samplerIndex = samplerIndex; + samplerIndex++; + } + + uniform.isArray = + uniformInfo.size > 1 || + uniform.type === gl.FLOAT_MAT3 || + uniform.type === gl.FLOAT_MAT4 || + uniform.type === gl.FLOAT_VEC2 || + uniform.type === gl.FLOAT_VEC3 || + uniform.type === gl.FLOAT_VEC4 || + uniform.type === gl.INT_VEC2 || + uniform.type === gl.INT_VEC4 || + uniform.type === gl.INT_VEC3; + + result.push(uniform); + } + + return result; +} + +export function getWebGLShaderAttributes(shader, gl) { + const attributes = {}; + + const numAttributes = gl.getProgramParameter( + shader._glProgram, + gl.ACTIVE_ATTRIBUTES + ); + for (let i = 0; i < numAttributes; ++i) { + const attributeInfo = gl.getActiveAttrib(shader._glProgram, i); + const name = attributeInfo.name; + const location = gl.getAttribLocation(shader._glProgram, name); + const attribute = {}; + attribute.name = name; + attribute.location = location; + attribute.index = i; + attribute.type = attributeInfo.type; + attribute.size = attributeInfo.size; + attributes[name] = attribute; + } + + return attributes; +} + +export function populateGLSLHooks(shader, src, shaderType) { + const main = 'void main'; + if (!src.includes(main)) return src; + + let [preMain, postMain] = src.split(main); + + let hooks = ''; + let defines = ''; + for (const key in shader.hooks.uniforms) { + hooks += `uniform ${key};\n`; + } + if (shader.hooks.declarations) { + hooks += shader.hooks.declarations + '\n'; + } + if (shader.hooks[shaderType].declarations) { + hooks += shader.hooks[shaderType].declarations + '\n'; + } + for (const hookDef in shader.hooks.helpers) { + hooks += `${hookDef}${shader.hooks.helpers[hookDef]}\n`; + } + for (const hookDef in shader.hooks[shaderType]) { + if (hookDef === 'declarations') continue; + const [hookType, hookName] = hookDef.split(' '); + + // Add a #define so that if the shader wants to use preprocessor directives to + // optimize away the extra function calls in main, it can do so + if ( + shader.hooks.modified.vertex[hookDef] || + shader.hooks.modified.fragment[hookDef] + ) { + defines += '#define AUGMENTED_HOOK_' + hookName + '\n'; + } + + hooks += + hookType + ' HOOK_' + hookName + shader.hooks[shaderType][hookDef] + '\n'; + } + + // Allow shaders to specify the location of hook #define statements. Normally these + // go after function definitions, but one might want to have them defined earlier + // in order to only conditionally make uniforms. + if (preMain.indexOf('#define HOOK_DEFINES') !== -1) { + preMain = preMain.replace('#define HOOK_DEFINES', '\n' + defines + '\n'); + defines = ''; + } + + return preMain + '\n' + defines + hooks + main + postMain; +} diff --git a/test/unit/webgl/p5.RendererGL.js b/test/unit/webgl/p5.RendererGL.js index 93d66c790a..34b64abdfd 100644 --- a/test/unit/webgl/p5.RendererGL.js +++ b/test/unit/webgl/p5.RendererGL.js @@ -107,10 +107,9 @@ suite('p5.RendererGL', function() { // Make a red texture const tex = myp5.createFramebuffer(); tex.draw(() => myp5.background('red')); - console.log(tex.get().canvas.toDataURL()); myp5.shader(myShader); - myp5.fill('red') + myp5.fill('blue') myp5.noStroke(); myShader.setUniform('myTex', tex); diff --git a/vitest.workspace.mjs b/vitest.workspace.mjs index 14bac25ce4..7dfe0e6e82 100644 --- a/vitest.workspace.mjs +++ b/vitest.workspace.mjs @@ -5,7 +5,8 @@ const plugins = [ vitePluginString({ include: [ 'src/webgl/shaders/**/*' - ] + ], + compress: false, }) ]; From aef17d5c6f2ef15dddc5c2663a2304ae982cd4ec Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Tue, 17 Jun 2025 19:57:12 -0400 Subject: [PATCH 29/72] Fix the rest of the tests! --- src/image/filterRenderer2D.js | 4 +- src/webgl/p5.Texture.js | 4 +- test/unit/webgl/light.js | 1 + test/unit/webgl/p5.Framebuffer.js | 12 +++--- test/unit/webgl/p5.Shader.js | 1 - test/unit/webgl/p5.Texture.js | 64 +++++++++++++++++-------------- 6 files changed, 47 insertions(+), 39 deletions(-) diff --git a/src/image/filterRenderer2D.js b/src/image/filterRenderer2D.js index cfdf10eb8d..e2a5aa1f5d 100644 --- a/src/image/filterRenderer2D.js +++ b/src/image/filterRenderer2D.js @@ -60,8 +60,8 @@ class FilterRenderer2D { _emptyTexture: null, webglVersion, states: { - textureWrapX: this.gl.CLAMP_TO_EDGE, - textureWrapY: this.gl.CLAMP_TO_EDGE, + textureWrapX: constants.CLAMP, + textureWrapY: constants.CLAMP, }, _arraysEqual: (a, b) => JSON.stringify(a) === JSON.stringify(b), _getEmptyTexture: () => { diff --git a/src/webgl/p5.Texture.js b/src/webgl/p5.Texture.js index e7103bd13c..c88389bb8e 100644 --- a/src/webgl/p5.Texture.js +++ b/src/webgl/p5.Texture.js @@ -23,8 +23,8 @@ class Texture { this.format = settings.format || 'rgba8unorm'; this.minFilter = settings.minFilter || constants.LINEAR; this.magFilter = settings.magFilter || constants.LINEAR; - this.wrapS = settings.wrapS || constants.CLAMP; - this.wrapT = settings.wrapT || constants.CLAMP; + this.wrapS = settings.wrapS || renderer.states.textureWrapX; + this.wrapT = settings.wrapT || renderer.states.textureWrapY; this.dataType = settings.dataType || 'uint8'; this.textureHandle = null; diff --git a/test/unit/webgl/light.js b/test/unit/webgl/light.js index 3f8785a5c9..38aa248003 100644 --- a/test/unit/webgl/light.js +++ b/test/unit/webgl/light.js @@ -67,6 +67,7 @@ suite('light', function() { }); suite('spotlight inputs', function() { + beforeEach(() => myp5.noLights()); let angle = Math.PI / 4; let defaultAngle = Math.cos(Math.PI / 3); let cosAngle = Math.cos(angle); diff --git a/test/unit/webgl/p5.Framebuffer.js b/test/unit/webgl/p5.Framebuffer.js index 8d52a1668c..f97cb6b57d 100644 --- a/test/unit/webgl/p5.Framebuffer.js +++ b/test/unit/webgl/p5.Framebuffer.js @@ -156,7 +156,7 @@ suite('p5.Framebuffer', function() { expect(fbo.density).to.equal(1); // The texture should not be recreated - expect(fbo.color.rawTexture()).to.equal(oldTexture); + expect(fbo.color.rawTexture().texture).to.equal(oldTexture.texture); }); test('manually-sized framebuffers can be made auto-sized', function() { @@ -216,7 +216,7 @@ suite('p5.Framebuffer', function() { expect(fbo.density).to.equal(2); // The texture should not be recreated - expect(fbo.color.rawTexture()).to.equal(oldTexture); + expect(fbo.color.rawTexture().texture).to.equal(oldTexture.texture); }); test('resizes the framebuffer by createFramebuffer based on max texture size', function() { @@ -638,10 +638,10 @@ suite('p5.Framebuffer', function() { }); assert.equal( - fbo.color.framebuffer.colorP5Texture.glMinFilter, fbo.gl.NEAREST + fbo.color.framebuffer.colorP5Texture.minFilter, myp5.NEAREST ); assert.equal( - fbo.color.framebuffer.colorP5Texture.glMagFilter, fbo.gl.NEAREST + fbo.color.framebuffer.colorP5Texture.magFilter, myp5.NEAREST ); }); test('can create a framebuffer that uses LINEAR texture filtering', @@ -651,10 +651,10 @@ suite('p5.Framebuffer', function() { const fbo = myp5.createFramebuffer({}); assert.equal( - fbo.color.framebuffer.colorP5Texture.glMinFilter, fbo.gl.LINEAR + fbo.color.framebuffer.colorP5Texture.minFilter, myp5.LINEAR ); assert.equal( - fbo.color.framebuffer.colorP5Texture.glMagFilter, fbo.gl.LINEAR + fbo.color.framebuffer.colorP5Texture.magFilter, myp5.LINEAR ); }); }); diff --git a/test/unit/webgl/p5.Shader.js b/test/unit/webgl/p5.Shader.js index 00d4d00847..7a7d35021b 100644 --- a/test/unit/webgl/p5.Shader.js +++ b/test/unit/webgl/p5.Shader.js @@ -67,7 +67,6 @@ suite('p5.Shader', function() { 'uModelViewMatrix', 'uProjectionMatrix', 'uNormalMatrix', - 'uAmbientLightCount', 'uDirectionalLightCount', 'uPointLightCount', 'uAmbientColor', diff --git a/test/unit/webgl/p5.Texture.js b/test/unit/webgl/p5.Texture.js index 80512f0e49..60058b302d 100644 --- a/test/unit/webgl/p5.Texture.js +++ b/test/unit/webgl/p5.Texture.js @@ -67,6 +67,13 @@ suite('p5.Texture', function() { }; suite('p5.Texture', function() { + let texParamSpy; + beforeEach(() => { + texParamSpy = vi.spyOn(myp5._renderer.GL, 'texParameteri'); + }); + afterEach(() => { + vi.restoreAllMocks(); + }); test('Create and cache a single texture with p5.Image', function() { testTextureSet(texImg1); }); @@ -79,56 +86,57 @@ suite('p5.Texture', function() { test('Set filter mode to linear', function() { var tex = myp5._renderer.getTexture(texImg2); tex.setInterpolation(myp5.LINEAR, myp5.LINEAR); - assert.deepEqual(tex.glMinFilter, myp5._renderer.GL.LINEAR); - assert.deepEqual(tex.glMagFilter, myp5._renderer.GL.LINEAR); + expect(texParamSpy).toHaveBeenCalledWith(myp5._renderer.GL.TEXTURE_2D, myp5._renderer.GL.TEXTURE_MIN_FILTER, myp5._renderer.GL.LINEAR); + expect(texParamSpy).toHaveBeenCalledWith(myp5._renderer.GL.TEXTURE_2D, myp5._renderer.GL.TEXTURE_MAG_FILTER, myp5._renderer.GL.LINEAR); }); test('Set filter mode to nearest', function() { var tex = myp5._renderer.getTexture(texImg2); tex.setInterpolation(myp5.NEAREST, myp5.NEAREST); - assert.deepEqual(tex.glMinFilter, myp5._renderer.GL.NEAREST); - assert.deepEqual(tex.glMagFilter, myp5._renderer.GL.NEAREST); + expect(texParamSpy).toHaveBeenCalledWith(myp5._renderer.GL.TEXTURE_2D, myp5._renderer.GL.TEXTURE_MIN_FILTER, myp5._renderer.GL.NEAREST); + expect(texParamSpy).toHaveBeenCalledWith(myp5._renderer.GL.TEXTURE_2D, myp5._renderer.GL.TEXTURE_MAG_FILTER, myp5._renderer.GL.NEAREST); }); test('Set wrap mode to clamp', function() { var tex = myp5._renderer.getTexture(texImg2); tex.setWrapMode(myp5.CLAMP, myp5.CLAMP); - assert.deepEqual(tex.glWrapS, myp5._renderer.GL.CLAMP_TO_EDGE); - assert.deepEqual(tex.glWrapT, myp5._renderer.GL.CLAMP_TO_EDGE); + expect(texParamSpy).toHaveBeenCalledWith(myp5._renderer.GL.TEXTURE_2D, myp5._renderer.GL.TEXTURE_WRAP_S, myp5._renderer.GL.CLAMP_TO_EDGE); + expect(texParamSpy).toHaveBeenCalledWith(myp5._renderer.GL.TEXTURE_2D, myp5._renderer.GL.TEXTURE_WRAP_T, myp5._renderer.GL.CLAMP_TO_EDGE); }); test('Set wrap mode to repeat', function() { var tex = myp5._renderer.getTexture(texImg2); tex.setWrapMode(myp5.REPEAT, myp5.REPEAT); - assert.deepEqual(tex.glWrapS, myp5._renderer.GL.REPEAT); - assert.deepEqual(tex.glWrapT, myp5._renderer.GL.REPEAT); + expect(texParamSpy).toHaveBeenCalledWith(myp5._renderer.GL.TEXTURE_2D, myp5._renderer.GL.TEXTURE_WRAP_S, myp5._renderer.GL.REPEAT); + expect(texParamSpy).toHaveBeenCalledWith(myp5._renderer.GL.TEXTURE_2D, myp5._renderer.GL.TEXTURE_WRAP_T, myp5._renderer.GL.REPEAT); }); test('Set wrap mode to mirror', function() { var tex = myp5._renderer.getTexture(texImg2); tex.setWrapMode(myp5.MIRROR, myp5.MIRROR); - assert.deepEqual(tex.glWrapS, myp5._renderer.GL.MIRRORED_REPEAT); - assert.deepEqual(tex.glWrapT, myp5._renderer.GL.MIRRORED_REPEAT); + expect(texParamSpy).toHaveBeenCalledWith(myp5._renderer.GL.TEXTURE_2D, myp5._renderer.GL.TEXTURE_WRAP_S, myp5._renderer.GL.MIRRORED_REPEAT); + expect(texParamSpy).toHaveBeenCalledWith(myp5._renderer.GL.TEXTURE_2D, myp5._renderer.GL.TEXTURE_WRAP_T, myp5._renderer.GL.MIRRORED_REPEAT); }); test('Set wrap mode REPEAT if src dimensions is powerOfTwo', function() { const tex = myp5._renderer.getTexture(imgElementPowerOfTwo); tex.setWrapMode(myp5.REPEAT, myp5.REPEAT); - assert.deepEqual(tex.glWrapS, myp5._renderer.GL.REPEAT); - assert.deepEqual(tex.glWrapT, myp5._renderer.GL.REPEAT); + expect(texParamSpy).toHaveBeenCalledWith(myp5._renderer.GL.TEXTURE_2D, myp5._renderer.GL.TEXTURE_WRAP_S, myp5._renderer.GL.REPEAT); + expect(texParamSpy).toHaveBeenCalledWith(myp5._renderer.GL.TEXTURE_2D, myp5._renderer.GL.TEXTURE_WRAP_T, myp5._renderer.GL.REPEAT); }); test( 'Set default wrap mode REPEAT if WEBGL2 and src dimensions != powerOfTwo', function() { const tex = myp5._renderer.getTexture(imgElementNotPowerOfTwo); tex.setWrapMode(myp5.REPEAT, myp5.REPEAT); - assert.deepEqual(tex.glWrapS, myp5._renderer.GL.REPEAT); - assert.deepEqual(tex.glWrapT, myp5._renderer.GL.REPEAT); + expect(texParamSpy).toHaveBeenCalledWith(myp5._renderer.GL.TEXTURE_2D, myp5._renderer.GL.TEXTURE_WRAP_S, myp5._renderer.GL.REPEAT); + expect(texParamSpy).toHaveBeenCalledWith(myp5._renderer.GL.TEXTURE_2D, myp5._renderer.GL.TEXTURE_WRAP_T, myp5._renderer.GL.REPEAT); } ); test( 'Set default wrap mode CLAMP if WEBGL1 and src dimensions != powerOfTwo', function() { myp5.setAttributes({ version: 1 }); + texParamSpy = vi.spyOn(myp5._renderer.GL, 'texParameteri'); const tex = myp5._renderer.getTexture(imgElementNotPowerOfTwo); tex.setWrapMode(myp5.REPEAT, myp5.REPEAT); - assert.deepEqual(tex.glWrapS, myp5._renderer.GL.CLAMP_TO_EDGE); - assert.deepEqual(tex.glWrapT, myp5._renderer.GL.CLAMP_TO_EDGE); + expect(texParamSpy).toHaveBeenCalledWith(myp5._renderer.GL.TEXTURE_2D, myp5._renderer.GL.TEXTURE_WRAP_S, myp5._renderer.GL.CLAMP_TO_EDGE); + expect(texParamSpy).toHaveBeenCalledWith(myp5._renderer.GL.TEXTURE_2D, myp5._renderer.GL.TEXTURE_WRAP_T, myp5._renderer.GL.CLAMP_TO_EDGE); } ); test('Set textureMode to NORMAL', function() { @@ -143,28 +151,28 @@ suite('p5.Texture', function() { myp5.textureWrap(myp5.CLAMP); var tex1 = myp5._renderer.getTexture(texImg1); var tex2 = myp5._renderer.getTexture(texImg2); - assert.deepEqual(tex1.glWrapS, myp5._renderer.GL.CLAMP_TO_EDGE); - assert.deepEqual(tex1.glWrapT, myp5._renderer.GL.CLAMP_TO_EDGE); - assert.deepEqual(tex2.glWrapS, myp5._renderer.GL.CLAMP_TO_EDGE); - assert.deepEqual(tex2.glWrapT, myp5._renderer.GL.CLAMP_TO_EDGE); + expect(texParamSpy).toHaveBeenCalledWith(myp5._renderer.GL.TEXTURE_2D, myp5._renderer.GL.TEXTURE_WRAP_S, myp5._renderer.GL.CLAMP_TO_EDGE); + expect(texParamSpy).toHaveBeenCalledWith(myp5._renderer.GL.TEXTURE_2D, myp5._renderer.GL.TEXTURE_WRAP_T, myp5._renderer.GL.CLAMP_TO_EDGE); + expect(texParamSpy).toHaveBeenCalledWith(myp5._renderer.GL.TEXTURE_2D, myp5._renderer.GL.TEXTURE_WRAP_S, myp5._renderer.GL.CLAMP_TO_EDGE); + expect(texParamSpy).toHaveBeenCalledWith(myp5._renderer.GL.TEXTURE_2D, myp5._renderer.GL.TEXTURE_WRAP_T, myp5._renderer.GL.CLAMP_TO_EDGE); }); test('Set global wrap mode to repeat', function() { myp5.textureWrap(myp5.REPEAT); var tex1 = myp5._renderer.getTexture(texImg1); var tex2 = myp5._renderer.getTexture(texImg2); - assert.deepEqual(tex1.glWrapS, myp5._renderer.GL.REPEAT); - assert.deepEqual(tex1.glWrapT, myp5._renderer.GL.REPEAT); - assert.deepEqual(tex2.glWrapS, myp5._renderer.GL.REPEAT); - assert.deepEqual(tex2.glWrapT, myp5._renderer.GL.REPEAT); + expect(texParamSpy).toHaveBeenCalledWith(myp5._renderer.GL.TEXTURE_2D, myp5._renderer.GL.TEXTURE_WRAP_S, myp5._renderer.GL.REPEAT); + expect(texParamSpy).toHaveBeenCalledWith(myp5._renderer.GL.TEXTURE_2D, myp5._renderer.GL.TEXTURE_WRAP_T, myp5._renderer.GL.REPEAT); + expect(texParamSpy).toHaveBeenCalledWith(myp5._renderer.GL.TEXTURE_2D, myp5._renderer.GL.TEXTURE_WRAP_S, myp5._renderer.GL.REPEAT); + expect(texParamSpy).toHaveBeenCalledWith(myp5._renderer.GL.TEXTURE_2D, myp5._renderer.GL.TEXTURE_WRAP_T, myp5._renderer.GL.REPEAT); }); test('Set global wrap mode to mirror', function() { myp5.textureWrap(myp5.MIRROR); var tex1 = myp5._renderer.getTexture(texImg1); var tex2 = myp5._renderer.getTexture(texImg2); - assert.deepEqual(tex1.glWrapS, myp5._renderer.GL.MIRRORED_REPEAT); - assert.deepEqual(tex1.glWrapT, myp5._renderer.GL.MIRRORED_REPEAT); - assert.deepEqual(tex2.glWrapS, myp5._renderer.GL.MIRRORED_REPEAT); - assert.deepEqual(tex2.glWrapT, myp5._renderer.GL.MIRRORED_REPEAT); + expect(texParamSpy).toHaveBeenCalledWith(myp5._renderer.GL.TEXTURE_2D, myp5._renderer.GL.TEXTURE_WRAP_S, myp5._renderer.GL.MIRRORED_REPEAT); + expect(texParamSpy).toHaveBeenCalledWith(myp5._renderer.GL.TEXTURE_2D, myp5._renderer.GL.TEXTURE_WRAP_T, myp5._renderer.GL.MIRRORED_REPEAT); + expect(texParamSpy).toHaveBeenCalledWith(myp5._renderer.GL.TEXTURE_2D, myp5._renderer.GL.TEXTURE_WRAP_S, myp5._renderer.GL.MIRRORED_REPEAT); + expect(texParamSpy).toHaveBeenCalledWith(myp5._renderer.GL.TEXTURE_2D, myp5._renderer.GL.TEXTURE_WRAP_T, myp5._renderer.GL.MIRRORED_REPEAT); }); test('Handles changes to p5.Image size', function() { const tex = myp5._renderer.getTexture(texImg2); From abc40743d912e10aac4db2307fd42ddea1f36d13 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Wed, 25 Jun 2025 19:50:06 -0400 Subject: [PATCH 30/72] Get textures working --- preview/index.html | 34 +++++++++--- src/core/p5.Renderer3D.js | 16 ++++++ src/webgl/p5.RendererGL.js | 16 ------ src/webgl/p5.Shader.js | 1 + src/webgpu/p5.RendererWebGPU.js | 95 +++++++++++++++++++++++---------- src/webgpu/shaders/material.js | 9 +++- 6 files changed, 119 insertions(+), 52 deletions(-) diff --git a/preview/index.html b/preview/index.html index 99a5bc38d5..6e4915ab34 100644 --- a/preview/index.html +++ b/preview/index.html @@ -26,42 +26,59 @@ let fbo; let sh; let ssh; + let tex; p.setup = async function () { await p.createCanvas(400, 400, p.WEBGPU); + + tex = p.createImage(100, 100); + tex.loadPixels(); + for (let x = 0; x < tex.width; x++) { + for (let y = 0; y < tex.height; y++) { + const off = (x + y * tex.width) * 4; + tex.pixels[off] = p.round((x / tex.width) * 255); + tex.pixels[off + 1] = p.round((y / tex.height) * 255); + tex.pixels[off + 2] = 0; + tex.pixels[off + 3] = 255; + } + } + tex.updatePixels(); + sh = p.baseMaterialShader().modify({ uniforms: { 'f32 time': () => p.millis(), }, 'Vertex getWorldInputs': `(inputs: Vertex) { var result = inputs; - result.position.y += 40.0 * sin(uniforms.time * 0.01); + result.position.y += 40.0 * sin(uniforms.time * 0.005); return result; }`, }) - ssh = p.baseStrokeShader().modify({ + /*ssh = p.baseStrokeShader().modify({ uniforms: { 'f32 time': () => p.millis(), }, 'StrokeVertex getWorldInputs': `(inputs: StrokeVertex) { var result = inputs; - result.position.y += 40.0 * sin(uniforms.time * 0.01); + result.position.y += 40.0 * sin(uniforms.time * 0.005); return result; }`, - }) + })*/ }; p.draw = function () { - const t = p.millis() * 0.008; + p.orbitControl(); + const t = p.millis() * 0.002; p.background(200); p.shader(sh); - p.strokeShader(ssh) - p.ambientLight(50); + // p.strokeShader(ssh) + p.ambientLight(150); p.directionalLight(100, 100, 100, 0, 1, -1); p.pointLight(155, 155, 155, 0, -200, 500); p.specularMaterial(255); p.shininess(300); - p.stroke('white') + //p.stroke('white'); + p.noStroke(); for (const [i, c] of ['red', 'lime', 'blue'].entries()) { p.push(); p.fill(c); @@ -70,6 +87,7 @@ 0, //p.width/3 * p.sin(t * 0.9 + i * Math.E + 0.2), p.width/3 * p.sin(t * 1.2 + i * Math.E + 0.3), ) + p.texture(tex) p.sphere(30); p.pop(); } diff --git a/src/core/p5.Renderer3D.js b/src/core/p5.Renderer3D.js index c437f34725..f43326b908 100644 --- a/src/core/p5.Renderer3D.js +++ b/src/core/p5.Renderer3D.js @@ -1586,6 +1586,22 @@ export class Renderer3D extends Renderer { return this._emptyTexture; } + getTexture(input) { + let src = input; + if (src instanceof Framebuffer) { + src = src.color; + } + + const texture = this.textures.get(src); + if (texture) { + return texture; + } + + const tex = new Texture(this, src); + this.textures.set(src, tex); + return tex; + } + ////////////////////////////////////////////// // Buffers ////////////////////////////////////////////// diff --git a/src/webgl/p5.RendererGL.js b/src/webgl/p5.RendererGL.js index c0d9ef244a..9025c0d31a 100644 --- a/src/webgl/p5.RendererGL.js +++ b/src/webgl/p5.RendererGL.js @@ -970,22 +970,6 @@ class RendererGL extends Renderer3D { return code; } - getTexture(input) { - let src = input; - if (src instanceof Framebuffer) { - src = src.color; - } - - const texture = this.textures.get(src); - if (texture) { - return texture; - } - - const tex = new Texture(this, src); - this.textures.set(src, tex); - return tex; - } - // TODO move to super class /* * used in imageLight, diff --git a/src/webgl/p5.Shader.js b/src/webgl/p5.Shader.js index 9a506a2801..2a95af1f17 100644 --- a/src/webgl/p5.Shader.js +++ b/src/webgl/p5.Shader.js @@ -708,6 +708,7 @@ class Shader { const empty = this._renderer._getEmptyTexture(); for (const uniform of this.samplers) { + if (uniform.noData) continue; let tex = uniform.texture; if ( tex === undefined || diff --git a/src/webgpu/p5.RendererWebGPU.js b/src/webgpu/p5.RendererWebGPU.js index 0b9dd6fcfa..8d434cc725 100644 --- a/src/webgpu/p5.RendererWebGPU.js +++ b/src/webgpu/p5.RendererWebGPU.js @@ -1,5 +1,6 @@ import { Renderer3D, getStrokeDefs } from '../core/p5.Renderer3D'; import { Shader } from '../webgl/p5.Shader'; +import { Texture } from '../webgl/p5.Texture'; import * as constants from '../core/constants'; @@ -252,7 +253,7 @@ class RendererWebGPU extends Renderer3D { _finalizeShader(shader) { const rawSize = Math.max( 0, - ...Object.values(shader.uniforms).map(u => u.offsetEnd) + ...Object.values(shader.uniforms).filter(u => !u.isSampler).map(u => u.offsetEnd) ); const alignedSize = Math.ceil(rawSize / 16) * 16; shader._uniformData = new Float32Array(alignedSize / 4); @@ -277,14 +278,17 @@ class RendererWebGPU extends Renderer3D { for (const sampler of shader.samplers) { const group = sampler.group; const entries = groupEntries.get(group) || []; + if (!['sampler', 'texture_2d'].includes(sampler.type)) { + throw new Error(`Unsupported texture type: ${sampler.type}`); + } entries.push({ binding: sampler.binding, - visibility: GPUShaderStage.FRAGMENT, + visibility: sampler.visibility, sampler: sampler.type === 'sampler' ? { type: 'filtering' } : undefined, - texture: sampler.type === 'texture' + texture: sampler.type === 'texture_2d' ? { sampleType: 'float', viewDimension: '2d' } : undefined, uniform: sampler, @@ -300,6 +304,7 @@ class RendererWebGPU extends Renderer3D { } shader._groupEntries = groupEntries; + console.log(shader._groupEntries); shader._bindGroupLayouts = [...bindGroupLayouts.values()]; shader._pipelineLayout = this.device.createPipelineLayout({ bindGroupLayouts: shader._bindGroupLayouts, @@ -617,11 +622,17 @@ class RendererWebGPU extends Renderer3D { }; } + if (!entry.uniform.isSampler) { + throw new Error( + 'All non-texture/sampler uniforms should be in the uniform struct!' + ); + } + return { binding: entry.binding, - resource: sampler.type === 'sampler' - ? sampler.uniform._cachedData.getSampler() - : sampler.uniform.textureHandle.view, + resource: entry.uniform.type === 'sampler' + ? (entry.uniform.textureSource.texture || this._getEmptyTexture()).getSampler() + : (entry.uniform.texture || this._getEmptyTexture()).textureHandle.view, }; }); @@ -799,29 +810,59 @@ class RendererWebGPU extends Renderer3D { const structType = uniformVarMatch[2]; const uniforms = this._parseStruct(shader.vertSrc(), structType); // Extract samplers from group bindings - const samplers = []; - const samplerRegex = /@group\((\d+)\)\s*@binding\((\d+)\)\s*var\s+(\w+)\s*:\s*(\w+);/g; - let match; - while ((match = samplerRegex.exec(shader._vertSrc)) !== null) { - const [_, group, binding, name, type] = match; - const groupIndex = parseInt(group); - // We're currently reserving group 0 for non-sampler stuff, which we parse - // above, so we can skip it here while we grab the remaining sampler - // uniforms - if (groupIndex === 0) continue; - - samplers.push({ - group: groupIndex, - binding: parseInt(binding), - name, - type, // e.g., 'sampler', 'texture_2d' - sampler: true, - }); + const samplers = {}; + // TODO: support other texture types + const samplerRegex = /@group\((\d+)\)\s*@binding\((\d+)\)\s*var\s+(\w+)\s*:\s*(texture_2d|sampler);/g; + for (const [src, visibility] of [ + [shader._vertSrc, GPUShaderStage.VERTEX], + [shader._fragSrc, GPUShaderStage.FRAGMENT] + ]) { + let match; + while ((match = samplerRegex.exec(src)) !== null) { + const [_, group, binding, name, type] = match; + const groupIndex = parseInt(group); + const bindingIndex = parseInt(binding); + // We're currently reserving group 0 for non-sampler stuff, which we parse + // above, so we can skip it here while we grab the remaining sampler + // uniforms + if (groupIndex === 0 && bindingIndex === 0) continue; + + const key = `${groupIndex},${bindingIndex}`; + samplers[key] = { + visibility: (samplers[key]?.visibility || 0) | visibility, + group: groupIndex, + binding: bindingIndex, + name, + type, + isSampler: true, + noData: type === 'sampler', + }; + } + + for (const sampler of Object.values(samplers)) { + if (sampler.type.startsWith('texture')) { + const samplerName = sampler.name + '_sampler'; + const samplerNode = Object + .values(samplers) + .find((s) => s.name === samplerName); + if (!samplerNode) { + throw new Error( + `Every shader texture needs an accompanying sampler. Could not find sampler ${samplerName} for texture ${sampler.name}` + ); + } + samplerNode.textureSource = sampler; + } + } } - return [...Object.values(uniforms).sort((a, b) => a.index - b.index), ...samplers]; + return [...Object.values(uniforms).sort((a, b) => a.index - b.index), ...Object.values(samplers)]; } - updateUniformValue(_shader, _uniform, _data) {} + updateUniformValue(_shader, uniform, data) { + if (uniform.isSampler) { + uniform.texture = + data instanceof Texture ? data : this.getTexture(data); + } + } _updateTexture(uniform, tex) { tex.update(); @@ -879,7 +920,7 @@ class RendererWebGPU extends Renderer3D { magFilter: constantMapping[texture.magFilter], minFilter: constantMapping[texture.minFilter], addressModeU: constantMapping[texture.wrapS], - addressModeV: constantMapping[params.addressModeV], + addressModeV: constantMapping[texture.wrapT], }); this.samplers.set(key, sampler); return sampler; diff --git a/src/webgpu/shaders/material.js b/src/webgpu/shaders/material.js index 9722daad06..774f131bce 100644 --- a/src/webgpu/shaders/material.js +++ b/src/webgpu/shaders/material.js @@ -145,6 +145,9 @@ struct FragmentInput { ${uniforms} @group(0) @binding(0) var uniforms: Uniforms; +@group(0) @binding(1) var uSampler: texture_2d; +@group(0) @binding(2) var uSampler_sampler: sampler; + struct ColorComponents { baseColor: vec3, opacity: f32, @@ -305,7 +308,11 @@ fn totalLight( fn main(input: FragmentInput) -> @location(0) vec4 { HOOK_beforeFragment(); - let color = input.vColor; // TODO: check isTexture and apply tint + let color = select( + input.vColor, + getTexture(uSampler, uSampler_sampler, input.vTexCoord) * (uniforms.uTint/255.0), + uniforms.isTexture == 1 + ); // TODO: check isTexture and apply tint var inputs = Inputs( normalize(input.vNormal), input.vTexCoord, From 397c1d82a7deafcfd1881b80dc6b36873d51dd33 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Thu, 26 Jun 2025 07:53:18 -0400 Subject: [PATCH 31/72] Add visual tests --- src/webgpu/p5.RendererWebGPU.js | 3 +- test/unit/visual/cases/webgpu.js | 108 ++++++++++++++++++ .../Shaders/Shader hooks can be used/000.png | Bin 0 -> 474 bytes .../Shader hooks can be used/metadata.json | 3 + .../000.png | Bin 0 -> 427 bytes .../metadata.json | 3 + .../000.png | Bin 0 -> 1707 bytes .../metadata.json | 3 + .../000.png | Bin 0 -> 510 bytes .../metadata.json | 3 + 10 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 test/unit/visual/cases/webgpu.js create mode 100644 test/unit/visual/screenshots/WebGPU/Shaders/Shader hooks can be used/000.png create mode 100644 test/unit/visual/screenshots/WebGPU/Shaders/Shader hooks can be used/metadata.json create mode 100644 test/unit/visual/screenshots/WebGPU/Shaders/The color shader runs successfully/000.png create mode 100644 test/unit/visual/screenshots/WebGPU/Shaders/The color shader runs successfully/metadata.json create mode 100644 test/unit/visual/screenshots/WebGPU/Shaders/The material shader runs successfully/000.png create mode 100644 test/unit/visual/screenshots/WebGPU/Shaders/The material shader runs successfully/metadata.json create mode 100644 test/unit/visual/screenshots/WebGPU/Shaders/The stroke shader runs successfully/000.png create mode 100644 test/unit/visual/screenshots/WebGPU/Shaders/The stroke shader runs successfully/metadata.json diff --git a/src/webgpu/p5.RendererWebGPU.js b/src/webgpu/p5.RendererWebGPU.js index 8d434cc725..8e8852ee41 100644 --- a/src/webgpu/p5.RendererWebGPU.js +++ b/src/webgpu/p5.RendererWebGPU.js @@ -304,7 +304,6 @@ class RendererWebGPU extends Renderer3D { } shader._groupEntries = groupEntries; - console.log(shader._groupEntries); shader._bindGroupLayouts = [...bindGroupLayouts.values()]; shader._pipelineLayout = this.device.createPipelineLayout({ bindGroupLayouts: shader._bindGroupLayouts, @@ -886,6 +885,7 @@ class RendererWebGPU extends Renderer3D { } uploadTextureFromSource({ gpuTexture }, source) { + this.uploadedTexture = true; this.queue.copyExternalImageToTexture( { source }, { texture: gpuTexture }, @@ -894,6 +894,7 @@ class RendererWebGPU extends Renderer3D { } uploadTextureFromData({ gpuTexture }, data, width, height) { + this.uploadedTexture = true; this.queue.writeTexture( { texture: gpuTexture }, data, diff --git a/test/unit/visual/cases/webgpu.js b/test/unit/visual/cases/webgpu.js new file mode 100644 index 0000000000..7593c437e8 --- /dev/null +++ b/test/unit/visual/cases/webgpu.js @@ -0,0 +1,108 @@ +import { vi } from 'vitest'; +import p5 from '../../../../src/app'; +import { visualSuite, visualTest } from '../visualTest'; +import rendererWebGPU from '../../../../src/webgpu/p5.RendererWebGPU'; + +p5.registerAddon(rendererWebGPU); + +visualSuite('WebGPU', function() { + visualSuite('Shaders', function() { + visualTest('The color shader runs successfully', async function(p5, screenshot) { + await p5.createCanvas(50, 50, p5.WEBGPU); + p5.background('white'); + for (const [i, color] of ['red', 'lime', 'blue'].entries()) { + p5.push(); + p5.rotate(p5.TWO_PI * (i / 3)); + p5.fill(color); + p5.translate(15, 0); + p5.noStroke(); + p5.circle(0, 0, 20); + p5.pop(); + } + screenshot(); + }); + + visualTest('The stroke shader runs successfully', async function(p5, screenshot) { + await p5.createCanvas(50, 50, p5.WEBGPU); + p5.background('white'); + for (const [i, color] of ['red', 'lime', 'blue'].entries()) { + p5.push(); + p5.rotate(p5.TWO_PI * (i / 3)); + p5.translate(15, 0); + p5.stroke(color); + p5.strokeWeight(2); + p5.circle(0, 0, 20); + p5.pop(); + } + screenshot(); + }); + + visualTest('The material shader runs successfully', async function(p5, screenshot) { + await p5.createCanvas(50, 50, p5.WEBGPU); + p5.background('white'); + p5.ambientLight(50); + p5.directionalLight(100, 100, 100, 0, 1, -1); + p5.pointLight(155, 155, 155, 0, -200, 500); + p5.specularMaterial(255); + p5.shininess(300); + for (const [i, color] of ['red', 'lime', 'blue'].entries()) { + p5.push(); + p5.rotate(p5.TWO_PI * (i / 3)); + p5.fill(color); + p5.translate(15, 0); + p5.noStroke(); + p5.sphere(10); + p5.pop(); + } + screenshot(); + }); + + visualTest('Shader hooks can be used', async function(p5, screenshot) { + await p5.createCanvas(50, 50, p5.WEBGPU); + const myFill = p5.baseMaterialShader().modify({ + 'Vertex getWorldInputs': `(inputs: Vertex) { + var result = inputs; + result.position.y += 10.0 * sin(inputs.position.x * 0.25); + return result; + }`, + }); + const myStroke = p5.baseStrokeShader().modify({ + 'StrokeVertex getWorldInputs': `(inputs: StrokeVertex) { + var result = inputs; + result.position.y += 10.0 * sin(inputs.position.x * 0.25); + return result; + }`, + }); + p5.background('black'); + p5.shader(myFill); + p5.strokeShader(myStroke); + p5.fill('red'); + p5.stroke('white'); + p5.strokeWeight(5); + p5.circle(0, 0, 30); + screenshot(); + }); + + // TODO: turns out textures are only available in the next animation frame! + // need to figure out a workaround before uncommenting this test. + /*visualTest('Textures in the material shader work', async function(p5, screenshot) { + await p5.createCanvas(50, 50, p5.WEBGPU); + const tex = p5.createImage(50, 50); + tex.loadPixels(); + for (let x = 0; x < tex.width; x++) { + for (let y = 0; y < tex.height; y++) { + const off = (x + y * tex.width) * 4; + tex.pixels[off] = p5.round((x / tex.width) * 255); + tex.pixels[off + 1] = p5.round((y / tex.height) * 255); + tex.pixels[off + 2] = 0; + tex.pixels[off + 3] = 255; + } + } + tex.updatePixels(); + p5.texture(tex); + p5.plane(p5.width, p5.height); + + screenshot(); + });*/ + }); +}); diff --git a/test/unit/visual/screenshots/WebGPU/Shaders/Shader hooks can be used/000.png b/test/unit/visual/screenshots/WebGPU/Shaders/Shader hooks can be used/000.png new file mode 100644 index 0000000000000000000000000000000000000000..f883a461b5b4ee71f22494a5cbbf31b750dee77d GIT binary patch literal 474 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 BBNhMv literal 0 HcmV?d00001 diff --git a/test/unit/visual/screenshots/WebGPU/Shaders/The material shader runs successfully/metadata.json b/test/unit/visual/screenshots/WebGPU/Shaders/The material shader runs successfully/metadata.json new file mode 100644 index 0000000000..2d4bfe30da --- /dev/null +++ b/test/unit/visual/screenshots/WebGPU/Shaders/The material shader runs successfully/metadata.json @@ -0,0 +1,3 @@ +{ + "numScreenshots": 1 +} \ No newline at end of file diff --git a/test/unit/visual/screenshots/WebGPU/Shaders/The stroke shader runs successfully/000.png b/test/unit/visual/screenshots/WebGPU/Shaders/The stroke shader runs successfully/000.png new file mode 100644 index 0000000000000000000000000000000000000000..cd8d8130754b37606c09f97debb75999a83fabc0 GIT binary patch literal 510 zcmVPx$xJg7oRA@u(m|G45AqYiv|BJS<(Fw64uj@?H+OH;~$l;2hxs+1U5B#zhBxj!f ziO`rqD8S5N>H9A6O(4}tOSG-yBamtkuT$Q6Rwxxn)S2jf4U`Ciasp+CgZd8;bQIC{ zs5(nU&qr;<(RV4*0XT;j-$jrbR!gJER^Hb3 zPi;v6qtVIu=PSEGMNZU29Z?mca}{_dRD@JmruO%?US%ryyQ)4hSE{3vgaxkJ07Wu1!U>eB4M_}wB}KDYIz~9;>jt4P^H-rL81`bU@UEX zPh3l0uotU#%1v$UzF48u5LFo)IQUi9cuGk{QXLrhKYpim2O*n+}YSB3_s~l(>A; zDneoU|Ik&}QS??*)LUyb5_)_9Lzr@8!`e`jFHIM#hLY?t*Z=?k07*qoM6N<$f}W_} A{{R30 literal 0 HcmV?d00001 diff --git a/test/unit/visual/screenshots/WebGPU/Shaders/The stroke shader runs successfully/metadata.json b/test/unit/visual/screenshots/WebGPU/Shaders/The stroke shader runs successfully/metadata.json new file mode 100644 index 0000000000..2d4bfe30da --- /dev/null +++ b/test/unit/visual/screenshots/WebGPU/Shaders/The stroke shader runs successfully/metadata.json @@ -0,0 +1,3 @@ +{ + "numScreenshots": 1 +} \ No newline at end of file From e7c1d6b83f9ed40eb43883472dd625a42cb8a53e Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Thu, 26 Jun 2025 18:35:29 -0400 Subject: [PATCH 32/72] Fix usage of textures in setup --- src/webgpu/p5.RendererWebGPU.js | 15 +++++++++++++-- test/unit/visual/cases/webgpu.js | 6 ++---- .../Textures in the material shader work/000.png | Bin 0 -> 275 bytes .../metadata.json | 3 +++ 4 files changed, 18 insertions(+), 6 deletions(-) create mode 100644 test/unit/visual/screenshots/WebGPU/Shaders/Textures in the material shader work/000.png create mode 100644 test/unit/visual/screenshots/WebGPU/Shaders/Textures in the material shader work/metadata.json diff --git a/src/webgpu/p5.RendererWebGPU.js b/src/webgpu/p5.RendererWebGPU.js index 8e8852ee41..9ded1277d2 100644 --- a/src/webgpu/p5.RendererWebGPU.js +++ b/src/webgpu/p5.RendererWebGPU.js @@ -36,6 +36,7 @@ class RendererWebGPU extends Renderer3D { // TODO disablable stencil this.depthFormat = 'depth24plus-stencil8'; this._updateSize(); + this._update(); } _updateSize() { @@ -662,6 +663,15 @@ class RendererWebGPU extends Renderer3D { this.queue.submit([commandEncoder.finish()]); } + async ensureTexture(source) { + await this.queue.onSubmittedWorkDone(); + await new Promise((res) => requestAnimationFrame(res)); + const tex = this.getTexture(source); + tex.update(); + await this.queue.onSubmittedWorkDone(); + await new Promise((res) => requestAnimationFrame(res)); + } + ////////////////////////////////////////////// // SHADER ////////////////////////////////////////////// @@ -885,7 +895,6 @@ class RendererWebGPU extends Renderer3D { } uploadTextureFromSource({ gpuTexture }, source) { - this.uploadedTexture = true; this.queue.copyExternalImageToTexture( { source }, { texture: gpuTexture }, @@ -894,7 +903,6 @@ class RendererWebGPU extends Renderer3D { } uploadTextureFromData({ gpuTexture }, data, width, height) { - this.uploadedTexture = true; this.queue.writeTexture( { texture: gpuTexture }, data, @@ -1118,6 +1126,9 @@ function rendererWebGPU(p5, fn) { p5.RendererWebGPU = RendererWebGPU; p5.renderers[constants.WEBGPU] = p5.RendererWebGPU; + fn.ensureTexture = function(source) { + return this._renderer.ensureTexture(source); + } } export default rendererWebGPU; diff --git a/test/unit/visual/cases/webgpu.js b/test/unit/visual/cases/webgpu.js index 7593c437e8..efd8cc7e93 100644 --- a/test/unit/visual/cases/webgpu.js +++ b/test/unit/visual/cases/webgpu.js @@ -83,9 +83,7 @@ visualSuite('WebGPU', function() { screenshot(); }); - // TODO: turns out textures are only available in the next animation frame! - // need to figure out a workaround before uncommenting this test. - /*visualTest('Textures in the material shader work', async function(p5, screenshot) { + visualTest('Textures in the material shader work', async function(p5, screenshot) { await p5.createCanvas(50, 50, p5.WEBGPU); const tex = p5.createImage(50, 50); tex.loadPixels(); @@ -103,6 +101,6 @@ visualSuite('WebGPU', function() { p5.plane(p5.width, p5.height); screenshot(); - });*/ + }); }); }); diff --git a/test/unit/visual/screenshots/WebGPU/Shaders/Textures in the material shader work/000.png b/test/unit/visual/screenshots/WebGPU/Shaders/Textures in the material shader work/000.png new file mode 100644 index 0000000000000000000000000000000000000000..b0e4b614b344a0205eb32d901c4f48631d7d2fda GIT binary patch literal 275 zcmeAS@N?(olHy`uVBq!ia0vp^Mj*_=1|;R|J2nC-#^NA%Cx&(BWL^R}XFXjULo%H2 zo);8mN@QTU@cu!QO6MdMPY}`olUTWn(q{c;J=@$mH?}g6YsnIaQ(Q|HIjrJZvdrNX*OG+}v$&Qlb-2Z~WU<38 zt|iMIesQHNaA@O7S>kYvD`k Date: Mon, 28 Jul 2025 11:04:11 -0400 Subject: [PATCH 33/72] Refactor framebuffers --- src/image/pixels.js | 2 +- src/webgl/p5.Framebuffer.js | 540 ++------------------------ src/webgl/p5.RendererGL.js | 597 ++++++++++++++++++++++++++++- src/webgl/p5.Texture.js | 21 - src/webgl/utils.js | 21 + src/webgpu/p5.RendererWebGPU.js | 382 ++++++++++++++++++ test/unit/visual/cases/webgpu.js | 164 ++++++++ test/unit/webgl/p5.RendererGL.js | 3 +- test/unit/webgpu/p5.Framebuffer.js | 247 ++++++++++++ 9 files changed, 1454 insertions(+), 523 deletions(-) create mode 100644 test/unit/webgpu/p5.Framebuffer.js diff --git a/src/image/pixels.js b/src/image/pixels.js index ebea101273..6c2ea58115 100644 --- a/src/image/pixels.js +++ b/src/image/pixels.js @@ -933,7 +933,7 @@ function pixels(p5, fn){ */ fn.loadPixels = function(...args) { // p5._validateParameters('loadPixels', args); - this._renderer.loadPixels(); + return this._renderer.loadPixels(); }; /** diff --git a/src/webgl/p5.Framebuffer.js b/src/webgl/p5.Framebuffer.js index 0ebb3c0daa..af2ab279b5 100644 --- a/src/webgl/p5.Framebuffer.js +++ b/src/webgl/p5.Framebuffer.js @@ -5,11 +5,9 @@ import * as constants from '../core/constants'; import { RGB, RGBA } from '../color/creating_reading'; -import { checkWebGLCapabilities } from './p5.Texture'; -import { readPixelsWebGL, readPixelWebGL } from './utils'; +import { checkWebGLCapabilities } from './utils'; import { Camera } from './p5.Camera'; import { Texture } from './p5.Texture'; -import { Image } from '../image/p5.Image'; const constrain = (n, low, high) => Math.max(Math.min(n, high), low); @@ -52,7 +50,6 @@ class FramebufferTexture { } rawTexture() { - // TODO: handle webgpu texture handle return { texture: this.framebuffer[this.property] }; } } @@ -87,13 +84,11 @@ class Framebuffer { this.antialiasSamples = settings.antialias ? 2 : 0; } this.antialias = this.antialiasSamples > 0; - if (this.antialias && this.renderer.webglVersion !== constants.WEBGL2) { - console.warn('Antialiasing is unsupported in a WebGL 1 context'); + if (this.antialias && !this.renderer.supportsFramebufferAntialias()) { + console.warn('Framebuffer antialiasing is unsupported in this context'); this.antialias = false; } this.density = settings.density || this.renderer._pixelDensity; - const gl = this.renderer.GL; - this.gl = gl; if (settings.width && settings.height) { const dimensions = this.renderer._adjustDimensions(settings.width, settings.height); @@ -112,7 +107,8 @@ class Framebuffer { this.height = this.renderer.height; this._autoSized = true; } - this._checkIfFormatsAvailable(); + // Let renderer validate and adjust formats for this context + this.renderer.validateFramebufferFormats(this); if (settings.stencil && !this.useDepth) { console.warn('A stencil buffer can only be used if also using depth. Since the framebuffer has no depth buffer, the stencil buffer will be ignored.'); @@ -120,16 +116,8 @@ class Framebuffer { this.useStencil = this.useDepth && (settings.stencil === undefined ? true : settings.stencil); - this.framebuffer = gl.createFramebuffer(); - if (!this.framebuffer) { - throw new Error('Unable to create a framebuffer'); - } - if (this.antialias) { - this.aaFramebuffer = gl.createFramebuffer(); - if (!this.aaFramebuffer) { - throw new Error('Unable to create a framebuffer for antialiasing'); - } - } + // Let renderer create framebuffer resources with antialiasing support + this.renderer.createFramebufferResources(this); this._recreateTextures(); @@ -466,6 +454,10 @@ class Framebuffer { } } + _deleteTextures() { + this.renderer.deleteFramebufferTextures(this); + } + /** * Creates new textures and renderbuffers given the current size of the * framebuffer. @@ -473,117 +465,10 @@ class Framebuffer { * @private */ _recreateTextures() { - const gl = this.gl; - this._updateSize(); - const prevBoundTexture = gl.getParameter(gl.TEXTURE_BINDING_2D); - const prevBoundFramebuffer = gl.getParameter(gl.FRAMEBUFFER_BINDING); - - const colorTexture = gl.createTexture(); - if (!colorTexture) { - throw new Error('Unable to create color texture'); - } - gl.bindTexture(gl.TEXTURE_2D, colorTexture); - const colorFormat = this._glColorFormat(); - gl.texImage2D( - gl.TEXTURE_2D, - 0, - colorFormat.internalFormat, - this.width * this.density, - this.height * this.density, - 0, - colorFormat.format, - colorFormat.type, - null - ); - this.colorTexture = colorTexture; - gl.bindFramebuffer(gl.FRAMEBUFFER, this.framebuffer); - gl.framebufferTexture2D( - gl.FRAMEBUFFER, - gl.COLOR_ATTACHMENT0, - gl.TEXTURE_2D, - colorTexture, - 0 - ); - - if (this.useDepth) { - // Create the depth texture - const depthTexture = gl.createTexture(); - if (!depthTexture) { - throw new Error('Unable to create depth texture'); - } - const depthFormat = this._glDepthFormat(); - gl.bindTexture(gl.TEXTURE_2D, depthTexture); - gl.texImage2D( - gl.TEXTURE_2D, - 0, - depthFormat.internalFormat, - this.width * this.density, - this.height * this.density, - 0, - depthFormat.format, - depthFormat.type, - null - ); - - gl.framebufferTexture2D( - gl.FRAMEBUFFER, - this.useStencil ? gl.DEPTH_STENCIL_ATTACHMENT : gl.DEPTH_ATTACHMENT, - gl.TEXTURE_2D, - depthTexture, - 0 - ); - this.depthTexture = depthTexture; - } - - // Create separate framebuffer for antialiasing - if (this.antialias) { - this.colorRenderbuffer = gl.createRenderbuffer(); - gl.bindRenderbuffer(gl.RENDERBUFFER, this.colorRenderbuffer); - gl.renderbufferStorageMultisample( - gl.RENDERBUFFER, - Math.max( - 0, - Math.min(this.antialiasSamples, gl.getParameter(gl.MAX_SAMPLES)) - ), - colorFormat.internalFormat, - this.width * this.density, - this.height * this.density - ); - - if (this.useDepth) { - const depthFormat = this._glDepthFormat(); - this.depthRenderbuffer = gl.createRenderbuffer(); - gl.bindRenderbuffer(gl.RENDERBUFFER, this.depthRenderbuffer); - gl.renderbufferStorageMultisample( - gl.RENDERBUFFER, - Math.max( - 0, - Math.min(this.antialiasSamples, gl.getParameter(gl.MAX_SAMPLES)) - ), - depthFormat.internalFormat, - this.width * this.density, - this.height * this.density - ); - } - - gl.bindFramebuffer(gl.FRAMEBUFFER, this.aaFramebuffer); - gl.framebufferRenderbuffer( - gl.FRAMEBUFFER, - gl.COLOR_ATTACHMENT0, - gl.RENDERBUFFER, - this.colorRenderbuffer - ); - if (this.useDepth) { - gl.framebufferRenderbuffer( - gl.FRAMEBUFFER, - this.useStencil ? gl.DEPTH_STENCIL_ATTACHMENT : gl.DEPTH_ATTACHMENT, - gl.RENDERBUFFER, - this.depthRenderbuffer - ); - } - } + // Let renderer handle texture creation and framebuffer setup + this.renderer.recreateFramebufferTextures(this); if (this.useDepth) { this.depth = new FramebufferTexture(this, 'depthTexture'); @@ -612,131 +497,6 @@ class Framebuffer { } ); this.renderer.textures.set(this.color, this.colorP5Texture); - - gl.bindTexture(gl.TEXTURE_2D, prevBoundTexture); - gl.bindFramebuffer(gl.FRAMEBUFFER, prevBoundFramebuffer); - } - - /** - * To create a WebGL texture, one needs to supply three pieces of information: - * the type (the data type each channel will be stored as, e.g. int or float), - * the format (the color channels that will each be stored in the previously - * specified type, e.g. rgb or rgba), and the internal format (the specifics - * of how data for each channel, in the aforementioned type, will be packed - * together, such as how many bits to use, e.g. RGBA32F or RGB565.) - * - * The format and channels asked for by the user hint at what these values - * need to be, and the WebGL version affects what options are avaiable. - * This method returns the values for these three properties, given the - * framebuffer's settings. - * - * @private - */ - _glColorFormat() { - let type, format, internalFormat; - const gl = this.gl; - - if (this.format === constants.FLOAT) { - type = gl.FLOAT; - } else if (this.format === constants.HALF_FLOAT) { - type = this.renderer.webglVersion === constants.WEBGL2 - ? gl.HALF_FLOAT - : gl.getExtension('OES_texture_half_float').HALF_FLOAT_OES; - } else { - type = gl.UNSIGNED_BYTE; - } - - if (this.channels === RGBA) { - format = gl.RGBA; - } else { - format = gl.RGB; - } - - if (this.renderer.webglVersion === constants.WEBGL2) { - // https://webgl2fundamentals.org/webgl/lessons/webgl-data-textures.html - const table = { - [gl.FLOAT]: { - [gl.RGBA]: gl.RGBA32F - // gl.RGB32F is not available in Firefox without an alpha channel - }, - [gl.HALF_FLOAT]: { - [gl.RGBA]: gl.RGBA16F - // gl.RGB16F is not available in Firefox without an alpha channel - }, - [gl.UNSIGNED_BYTE]: { - [gl.RGBA]: gl.RGBA8, // gl.RGBA4 - [gl.RGB]: gl.RGB8 // gl.RGB565 - } - }; - internalFormat = table[type][format]; - } else if (this.format === constants.HALF_FLOAT) { - internalFormat = gl.RGBA; - } else { - internalFormat = format; - } - - return { internalFormat, format, type }; - } - - /** - * To create a WebGL texture, one needs to supply three pieces of information: - * the type (the data type each channel will be stored as, e.g. int or float), - * the format (the color channels that will each be stored in the previously - * specified type, e.g. rgb or rgba), and the internal format (the specifics - * of how data for each channel, in the aforementioned type, will be packed - * together, such as how many bits to use, e.g. RGBA32F or RGB565.) - * - * This method takes into account the settings asked for by the user and - * returns values for these three properties that can be used for the - * texture storing depth information. - * - * @private - */ - _glDepthFormat() { - let type, format, internalFormat; - const gl = this.gl; - - if (this.useStencil) { - if (this.depthFormat === constants.FLOAT) { - type = gl.FLOAT_32_UNSIGNED_INT_24_8_REV; - } else if (this.renderer.webglVersion === constants.WEBGL2) { - type = gl.UNSIGNED_INT_24_8; - } else { - type = gl.getExtension('WEBGL_depth_texture').UNSIGNED_INT_24_8_WEBGL; - } - } else { - if (this.depthFormat === constants.FLOAT) { - type = gl.FLOAT; - } else { - type = gl.UNSIGNED_INT; - } - } - - if (this.useStencil) { - format = gl.DEPTH_STENCIL; - } else { - format = gl.DEPTH_COMPONENT; - } - - if (this.useStencil) { - if (this.depthFormat === constants.FLOAT) { - internalFormat = gl.DEPTH32F_STENCIL8; - } else if (this.renderer.webglVersion === constants.WEBGL2) { - internalFormat = gl.DEPTH24_STENCIL8; - } else { - internalFormat = gl.DEPTH_STENCIL; - } - } else if (this.renderer.webglVersion === constants.WEBGL2) { - if (this.depthFormat === constants.FLOAT) { - internalFormat = gl.DEPTH_COMPONENT32F; - } else { - internalFormat = gl.DEPTH_COMPONENT24; - } - } else { - internalFormat = gl.DEPTH_COMPONENT; - } - - return { internalFormat, format, type }; } /** @@ -775,17 +535,7 @@ class Framebuffer { * @private */ _handleResize() { - const oldColor = this.color; - const oldDepth = this.depth; - const oldColorRenderbuffer = this.colorRenderbuffer; - const oldDepthRenderbuffer = this.depthRenderbuffer; - - this._deleteTexture(oldColor); - if (oldDepth) this._deleteTexture(oldDepth); - const gl = this.gl; - if (oldColorRenderbuffer) gl.deleteRenderbuffer(oldColorRenderbuffer); - if (oldDepthRenderbuffer) gl.deleteRenderbuffer(oldDepthRenderbuffer); - + this._deleteTextures(); this._recreateTextures(); this.defaultCamera._resize(); } @@ -913,20 +663,6 @@ class Framebuffer { return cam; } - /** - * Given a raw texture wrapper, delete its stored texture from WebGL memory, - * and remove it from p5's list of active textures. - * - * @param {p5.FramebufferTexture} texture - * @private - */ - _deleteTexture(texture) { - const gl = this.gl; - gl.deleteTexture(texture.rawTexture().texture); - - this.renderer.textures.delete(texture); - } - /** * Deletes the framebuffer from GPU memory. * @@ -996,19 +732,11 @@ class Framebuffer { *
*/ remove() { - const gl = this.gl; - this._deleteTexture(this.color); - if (this.depth) this._deleteTexture(this.depth); - gl.deleteFramebuffer(this.framebuffer); - if (this.aaFramebuffer) { - gl.deleteFramebuffer(this.aaFramebuffer); - } - if (this.depthRenderbuffer) { - gl.deleteRenderbuffer(this.depthRenderbuffer); - } - if (this.colorRenderbuffer) { - gl.deleteRenderbuffer(this.colorRenderbuffer); - } + this._deleteTextures(); + + // Let renderer clean up framebuffer resources + this.renderer.deleteFramebufferResources(this); + this.renderer.framebuffers.delete(this); } @@ -1095,14 +823,7 @@ class Framebuffer { * @private */ _framebufferToBind() { - if (this.antialias) { - // If antialiasing, draw to an antialiased renderbuffer rather - // than directly to the texture. In end() we will copy from the - // renderbuffer to the texture. - return this.aaFramebuffer; - } else { - return this.framebuffer; - } + return this.renderer.getFramebufferToBind(this); } /** @@ -1111,45 +832,9 @@ class Framebuffer { * @property {'colorTexutre'|'depthTexture'} property The property to update */ _update(property) { - if (this.dirty[property] && this.antialias) { - const gl = this.gl; - gl.bindFramebuffer(gl.READ_FRAMEBUFFER, this.aaFramebuffer); - gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, this.framebuffer); - const partsToCopy = { - colorTexture: [ - gl.COLOR_BUFFER_BIT, - // TODO: move to renderer - this.colorP5Texture.magFilter === constants.LINEAR ? gl.LINEAR : gl.NEAREST - ], - }; - if (this.useDepth) { - partsToCopy.depthTexture = [ - gl.DEPTH_BUFFER_BIT, - // TODO: move to renderer - this.depthP5Texture.magFilter === constants.LINEAR ? gl.LINEAR : gl.NEAREST - ]; - } - const [flag, filter] = partsToCopy[property]; - gl.blitFramebuffer( - 0, - 0, - this.width * this.density, - this.height * this.density, - 0, - 0, - this.width * this.density, - this.height * this.density, - flag, - filter - ); + if (this.dirty[property]) { + this.renderer.updateFramebufferTexture(this, property); this.dirty[property] = false; - - const activeFbo = this.renderer.activeFramebuffer(); - if (activeFbo) { - gl.bindFramebuffer(gl.FRAMEBUFFER, activeFbo._framebufferToBind()); - } else { - gl.bindFramebuffer(gl.FRAMEBUFFER, null); - } } } @@ -1159,8 +844,7 @@ class Framebuffer { * @private */ _beforeBegin() { - const gl = this.gl; - gl.bindFramebuffer(gl.FRAMEBUFFER, this._framebufferToBind()); + this.renderer.bindFramebuffer(this); this.renderer.viewport( this.width * this.density, this.height * this.density @@ -1236,7 +920,7 @@ class Framebuffer { if (this.prevFramebuffer) { this.prevFramebuffer._beforeBegin(); } else { - gl.bindFramebuffer(gl.FRAMEBUFFER, null); + this.renderer.bindFramebuffer(null); this.renderer.viewport( this.renderer._origViewport.width, this.renderer._origViewport.height @@ -1355,25 +1039,19 @@ class Framebuffer { */ loadPixels() { this._update('colorTexture'); - const gl = this.gl; - const prevFramebuffer = this.renderer.activeFramebuffer(); - gl.bindFramebuffer(gl.FRAMEBUFFER, this.framebuffer); - const colorFormat = this._glColorFormat(); - this.pixels = readPixelsWebGL( - this.pixels, - gl, - this.framebuffer, - 0, - 0, - this.width * this.density, - this.height * this.density, - colorFormat.format, - colorFormat.type - ); - if (prevFramebuffer) { - gl.bindFramebuffer(gl.FRAMEBUFFER, prevFramebuffer._framebufferToBind()); + const result = this.renderer.readFramebufferPixels(this); + + // Check if renderer returned a Promise (WebGPU) or data directly (WebGL) + if (result && typeof result.then === 'function') { + // WebGPU async case - return Promise + return result.then(pixels => { + this.pixels = pixels; + return pixels; + }); } else { - gl.bindFramebuffer(gl.FRAMEBUFFER, null); + // WebGL sync case - assign directly + this.pixels = result; + return result; } } @@ -1415,7 +1093,7 @@ class Framebuffer { get(x, y, w, h) { this._update('colorTexture'); // p5._validateParameters('p5.Framebuffer.get', arguments); - const colorFormat = this._glColorFormat(); + if (x === undefined && y === undefined) { x = 0; y = 0; @@ -1430,14 +1108,7 @@ class Framebuffer { y = constrain(y, 0, this.height - 1); } - return readPixelWebGL( - this.gl, - this.framebuffer, - x * this.density, - y * this.density, - colorFormat.format, - colorFormat.type - ); + return this.renderer.readFramebufferPixel(this, x * this.density, y * this.density); } x = constrain(x, 0, this.width - 1); @@ -1445,60 +1116,7 @@ class Framebuffer { w = constrain(w, 1, this.width - x); h = constrain(h, 1, this.height - y); - const rawData = readPixelsWebGL( - undefined, - this.gl, - this.framebuffer, - x * this.density, - y * this.density, - w * this.density, - h * this.density, - colorFormat.format, - colorFormat.type - ); - // Framebuffer data might be either a Uint8Array or Float32Array - // depending on its format, and it may or may not have an alpha channel. - // To turn it into an image, we have to normalize the data into a - // Uint8ClampedArray with alpha. - const fullData = new Uint8ClampedArray( - w * h * this.density * this.density * 4 - ); - - // Default channels that aren't in the framebuffer (e.g. alpha, if the - // framebuffer is in RGB mode instead of RGBA) to 255 - fullData.fill(255); - - const channels = colorFormat.type === this.gl.RGB ? 3 : 4; - for (let y = 0; y < h * this.density; y++) { - for (let x = 0; x < w * this.density; x++) { - for (let channel = 0; channel < 4; channel++) { - const idx = (y * w * this.density + x) * 4 + channel; - if (channel < channels) { - // Find the index of this pixel in `rawData`, which might have a - // different number of channels - const rawDataIdx = channels === 4 - ? idx - : (y * w * this.density + x) * channels + channel; - fullData[idx] = rawData[rawDataIdx]; - } - } - } - } - - // Create an image from the data - const region = new Image(w * this.density, h * this.density); - region.imageData = region.canvas.getContext('2d').createImageData( - region.width, - region.height - ); - region.imageData.data.set(fullData); - region.pixels = region.imageData.data; - region.updatePixels(); - if (this.density !== 1) { - // TODO: support get() at a pixel density > 1 - region.resize(w, h); - } - return region; + return this.renderer.readFramebufferRegion(this, x, y, w, h); } /** @@ -1550,85 +1168,9 @@ class Framebuffer { * */ updatePixels() { - const gl = this.gl; - this.colorP5Texture.bindTexture(); - const colorFormat = this._glColorFormat(); - - const channels = colorFormat.format === gl.RGBA ? 4 : 3; - const len = - this.width * this.height * this.density * this.density * channels; - const TypedArrayClass = colorFormat.type === gl.UNSIGNED_BYTE - ? Uint8Array - : Float32Array; - if ( - !(this.pixels instanceof TypedArrayClass) || this.pixels.length !== len - ) { - throw new Error( - 'The pixels array has not been set correctly. Please call loadPixels() before updatePixels().' - ); - } - - gl.texImage2D( - gl.TEXTURE_2D, - 0, - colorFormat.internalFormat, - this.width * this.density, - this.height * this.density, - 0, - colorFormat.format, - colorFormat.type, - this.pixels - ); - this.colorP5Texture.unbindTexture(); + // Let renderer handle the pixel update process + this.renderer.updateFramebufferPixels(this); this.dirty.colorTexture = false; - - const prevFramebuffer = this.renderer.activeFramebuffer(); - if (this.antialias) { - // We need to make sure the antialiased framebuffer also has the updated - // pixels so that if more is drawn to it, it goes on top of the updated - // pixels instead of replacing them. - // We can't blit the framebuffer to the multisampled antialias - // framebuffer to leave both in the same state, so instead we have - // to use image() to put the framebuffer texture onto the antialiased - // framebuffer. - this.begin(); - this.renderer.push(); - // this.renderer.imageMode(constants.CENTER); - this.renderer.states.setValue('imageMode', constants.CORNER); - this.renderer.setCamera(this.filterCamera); - this.renderer.resetMatrix(); - this.renderer.states.setValue('strokeColor', null); - this.renderer.clear(); - this.renderer._drawingFilter = true; - this.renderer.image( - this, - 0, 0, - this.width, this.height, - -this.renderer.width / 2, -this.renderer.height / 2, - this.renderer.width, this.renderer.height - ); - this.renderer._drawingFilter = false; - this.renderer.pop(); - if (this.useDepth) { - gl.clearDepth(1); - gl.clear(gl.DEPTH_BUFFER_BIT); - } - this.end(); - } else { - gl.bindFramebuffer(gl.FRAMEBUFFER, this.framebuffer); - if (this.useDepth) { - gl.clearDepth(1); - gl.clear(gl.DEPTH_BUFFER_BIT); - } - if (prevFramebuffer) { - gl.bindFramebuffer( - gl.FRAMEBUFFER, - prevFramebuffer._framebufferToBind() - ); - } else { - gl.bindFramebuffer(gl.FRAMEBUFFER, null); - } - } } } diff --git a/src/webgl/p5.RendererGL.js b/src/webgl/p5.RendererGL.js index 9025c0d31a..5a9482baa2 100644 --- a/src/webgl/p5.RendererGL.js +++ b/src/webgl/p5.RendererGL.js @@ -6,14 +6,17 @@ import { readPixelsWebGL, readPixelWebGL, setWebGLTextureParams, - setWebGLUniformValue + setWebGLUniformValue, + checkWebGLCapabilities } from './utils'; import { Renderer3D, getStrokeDefs } from "../core/p5.Renderer3D"; import { Shader } from "./p5.Shader"; import { Texture, MipmapTexture } from "./p5.Texture"; import { Framebuffer } from "./p5.Framebuffer"; import { Graphics } from "../core/p5.Graphics"; +import { RGB, RGBA } from '../color/creating_reading'; import { Element } from "../dom/p5.Element"; +import { Image } from '../image/p5.Image'; import filterBaseVert from "./shaders/filters/base.vert"; import lightingShader from "./shaders/lighting.glsl"; @@ -1386,6 +1389,598 @@ class RendererGL extends Renderer3D { populateHooks(shader, src, shaderType) { return populateGLSLHooks(shader, src, shaderType); } + + ////////////////////////////////////////////// + // Framebuffer methods + ////////////////////////////////////////////// + + supportsFramebufferAntialias() { + return this.webglVersion === constants.WEBGL2; + } + + createFramebufferResources(framebuffer) { + const gl = this.GL; + + framebuffer.framebuffer = gl.createFramebuffer(); + if (!framebuffer.framebuffer) { + throw new Error('Unable to create a framebuffer'); + } + + if (framebuffer.antialias) { + framebuffer.aaFramebuffer = gl.createFramebuffer(); + if (!framebuffer.aaFramebuffer) { + throw new Error('Unable to create a framebuffer for antialiasing'); + } + } + } + + validateFramebufferFormats(framebuffer) { + const gl = this.GL; + + if ( + framebuffer.useDepth && + this.webglVersion === constants.WEBGL && + !gl.getExtension('WEBGL_depth_texture') + ) { + console.warn( + 'Unable to create depth textures in this environment. Falling back ' + + 'to a framebuffer without depth.' + ); + framebuffer.useDepth = false; + } + + if ( + framebuffer.useDepth && + this.webglVersion === constants.WEBGL && + framebuffer.depthFormat === constants.FLOAT + ) { + console.warn( + 'FLOAT depth format is unavailable in WebGL 1. ' + + 'Defaulting to UNSIGNED_INT.' + ); + framebuffer.depthFormat = constants.UNSIGNED_INT; + } + + if (![ + constants.UNSIGNED_BYTE, + constants.FLOAT, + constants.HALF_FLOAT + ].includes(framebuffer.format)) { + console.warn( + 'Unknown Framebuffer format. ' + + 'Please use UNSIGNED_BYTE, FLOAT, or HALF_FLOAT. ' + + 'Defaulting to UNSIGNED_BYTE.' + ); + framebuffer.format = constants.UNSIGNED_BYTE; + } + if (framebuffer.useDepth && ![ + constants.UNSIGNED_INT, + constants.FLOAT + ].includes(framebuffer.depthFormat)) { + console.warn( + 'Unknown Framebuffer depth format. ' + + 'Please use UNSIGNED_INT or FLOAT. Defaulting to FLOAT.' + ); + framebuffer.depthFormat = constants.FLOAT; + } + + const support = checkWebGLCapabilities(this); + if (!support.float && framebuffer.format === constants.FLOAT) { + console.warn( + 'This environment does not support FLOAT textures. ' + + 'Falling back to UNSIGNED_BYTE.' + ); + framebuffer.format = constants.UNSIGNED_BYTE; + } + if ( + framebuffer.useDepth && + !support.float && + framebuffer.depthFormat === constants.FLOAT + ) { + console.warn( + 'This environment does not support FLOAT depth textures. ' + + 'Falling back to UNSIGNED_INT.' + ); + framebuffer.depthFormat = constants.UNSIGNED_INT; + } + if (!support.halfFloat && framebuffer.format === constants.HALF_FLOAT) { + console.warn( + 'This environment does not support HALF_FLOAT textures. ' + + 'Falling back to UNSIGNED_BYTE.' + ); + framebuffer.format = constants.UNSIGNED_BYTE; + } + + if ( + framebuffer.channels === RGB && + [constants.FLOAT, constants.HALF_FLOAT].includes(framebuffer.format) + ) { + console.warn( + 'FLOAT and HALF_FLOAT formats do not work cross-platform with only ' + + 'RGB channels. Falling back to RGBA.' + ); + framebuffer.channels = RGBA; + } + } + + recreateFramebufferTextures(framebuffer) { + const gl = this.GL; + + const prevBoundTexture = gl.getParameter(gl.TEXTURE_BINDING_2D); + const prevBoundFramebuffer = gl.getParameter(gl.FRAMEBUFFER_BINDING); + + const colorTexture = gl.createTexture(); + if (!colorTexture) { + throw new Error('Unable to create color texture'); + } + gl.bindTexture(gl.TEXTURE_2D, colorTexture); + const colorFormat = this._getFramebufferColorFormat(framebuffer); + gl.texImage2D( + gl.TEXTURE_2D, + 0, + colorFormat.internalFormat, + framebuffer.width * framebuffer.density, + framebuffer.height * framebuffer.density, + 0, + colorFormat.format, + colorFormat.type, + null + ); + framebuffer.colorTexture = colorTexture; + gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer.framebuffer); + gl.framebufferTexture2D( + gl.FRAMEBUFFER, + gl.COLOR_ATTACHMENT0, + gl.TEXTURE_2D, + colorTexture, + 0 + ); + + if (framebuffer.useDepth) { + // Create the depth texture + const depthTexture = gl.createTexture(); + if (!depthTexture) { + throw new Error('Unable to create depth texture'); + } + const depthFormat = this._getFramebufferDepthFormat(framebuffer); + gl.bindTexture(gl.TEXTURE_2D, depthTexture); + gl.texImage2D( + gl.TEXTURE_2D, + 0, + depthFormat.internalFormat, + framebuffer.width * framebuffer.density, + framebuffer.height * framebuffer.density, + 0, + depthFormat.format, + depthFormat.type, + null + ); + + gl.framebufferTexture2D( + gl.FRAMEBUFFER, + framebuffer.useStencil ? gl.DEPTH_STENCIL_ATTACHMENT : gl.DEPTH_ATTACHMENT, + gl.TEXTURE_2D, + depthTexture, + 0 + ); + framebuffer.depthTexture = depthTexture; + } + + // Create separate framebuffer for antialiasing + if (framebuffer.antialias) { + framebuffer.colorRenderbuffer = gl.createRenderbuffer(); + gl.bindRenderbuffer(gl.RENDERBUFFER, framebuffer.colorRenderbuffer); + gl.renderbufferStorageMultisample( + gl.RENDERBUFFER, + Math.max( + 0, + Math.min(framebuffer.antialiasSamples, gl.getParameter(gl.MAX_SAMPLES)) + ), + colorFormat.internalFormat, + framebuffer.width * framebuffer.density, + framebuffer.height * framebuffer.density + ); + + if (framebuffer.useDepth) { + const depthFormat = this._getFramebufferDepthFormat(framebuffer); + framebuffer.depthRenderbuffer = gl.createRenderbuffer(); + gl.bindRenderbuffer(gl.RENDERBUFFER, framebuffer.depthRenderbuffer); + gl.renderbufferStorageMultisample( + gl.RENDERBUFFER, + Math.max( + 0, + Math.min(framebuffer.antialiasSamples, gl.getParameter(gl.MAX_SAMPLES)) + ), + depthFormat.internalFormat, + framebuffer.width * framebuffer.density, + framebuffer.height * framebuffer.density + ); + } + + gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer.aaFramebuffer); + gl.framebufferRenderbuffer( + gl.FRAMEBUFFER, + gl.COLOR_ATTACHMENT0, + gl.RENDERBUFFER, + framebuffer.colorRenderbuffer + ); + if (framebuffer.useDepth) { + gl.framebufferRenderbuffer( + gl.FRAMEBUFFER, + framebuffer.useStencil ? gl.DEPTH_STENCIL_ATTACHMENT : gl.DEPTH_ATTACHMENT, + gl.RENDERBUFFER, + framebuffer.depthRenderbuffer + ); + } + } + + gl.bindTexture(gl.TEXTURE_2D, prevBoundTexture); + gl.bindFramebuffer(gl.FRAMEBUFFER, prevBoundFramebuffer); + } + + /** + * To create a WebGL texture, one needs to supply three pieces of information: + * the type (the data type each channel will be stored as, e.g. int or float), + * the format (the color channels that will each be stored in the previously + * specified type, e.g. rgb or rgba), and the internal format (the specifics + * of how data for each channel, in the aforementioned type, will be packed + * together, such as how many bits to use, e.g. RGBA32F or RGB565.) + * + * The format and channels asked for by the user hint at what these values + * need to be, and the WebGL version affects what options are avaiable. + * This method returns the values for these three properties, given the + * framebuffer's settings. + * + * @private + */ + _getFramebufferColorFormat(framebuffer) { + let type, format, internalFormat; + const gl = this.GL; + + if (framebuffer.format === constants.FLOAT) { + type = gl.FLOAT; + } else if (framebuffer.format === constants.HALF_FLOAT) { + type = this.webglVersion === constants.WEBGL2 + ? gl.HALF_FLOAT + : gl.getExtension('OES_texture_half_float').HALF_FLOAT_OES; + } else { + type = gl.UNSIGNED_BYTE; + } + + if (framebuffer.channels === RGBA) { + format = gl.RGBA; + } else { + format = gl.RGB; + } + + if (this.webglVersion === constants.WEBGL2) { + // https://webgl2fundamentals.org/webgl/lessons/webgl-data-textures.html + const table = { + [gl.FLOAT]: { + [gl.RGBA]: gl.RGBA32F + // gl.RGB32F is not available in Firefox without an alpha channel + }, + [gl.HALF_FLOAT]: { + [gl.RGBA]: gl.RGBA16F + // gl.RGB16F is not available in Firefox without an alpha channel + }, + [gl.UNSIGNED_BYTE]: { + [gl.RGBA]: gl.RGBA8, // gl.RGBA4 + [gl.RGB]: gl.RGB8 // gl.RGB565 + } + }; + internalFormat = table[type][format]; + } else if (framebuffer.format === constants.HALF_FLOAT) { + internalFormat = gl.RGBA; + } else { + internalFormat = format; + } + + return { internalFormat, format, type }; + } + + /** + * To create a WebGL texture, one needs to supply three pieces of information: + * the type (the data type each channel will be stored as, e.g. int or float), + * the format (the color channels that will each be stored in the previously + * specified type, e.g. rgb or rgba), and the internal format (the specifics + * of how data for each channel, in the aforementioned type, will be packed + * together, such as how many bits to use, e.g. RGBA32F or RGB565.) + * + * This method takes into account the settings asked for by the user and + * returns values for these three properties that can be used for the + * texture storing depth information. + * + * @private + */ + _getFramebufferDepthFormat(framebuffer) { + let type, format, internalFormat; + const gl = this.GL; + + if (framebuffer.useStencil) { + if (framebuffer.depthFormat === constants.FLOAT) { + type = gl.FLOAT_32_UNSIGNED_INT_24_8_REV; + } else if (this.webglVersion === constants.WEBGL2) { + type = gl.UNSIGNED_INT_24_8; + } else { + type = gl.getExtension('WEBGL_depth_texture').UNSIGNED_INT_24_8_WEBGL; + } + } else { + if (framebuffer.depthFormat === constants.FLOAT) { + type = gl.FLOAT; + } else { + type = gl.UNSIGNED_INT; + } + } + + if (framebuffer.useStencil) { + format = gl.DEPTH_STENCIL; + } else { + format = gl.DEPTH_COMPONENT; + } + + if (framebuffer.useStencil) { + if (framebuffer.depthFormat === constants.FLOAT) { + internalFormat = gl.DEPTH32F_STENCIL8; + } else if (this.webglVersion === constants.WEBGL2) { + internalFormat = gl.DEPTH24_STENCIL8; + } else { + internalFormat = gl.DEPTH_STENCIL; + } + } else if (this.webglVersion === constants.WEBGL2) { + if (framebuffer.depthFormat === constants.FLOAT) { + internalFormat = gl.DEPTH_COMPONENT32F; + } else { + internalFormat = gl.DEPTH_COMPONENT24; + } + } else { + internalFormat = gl.DEPTH_COMPONENT; + } + + return { internalFormat, format, type }; + } + + _deleteFramebufferTexture(texture) { + const gl = this.GL; + gl.deleteTexture(texture.rawTexture().texture); + this.textures.delete(texture); + } + + deleteFramebufferTextures(framebuffer) { + this._deleteFramebufferTexture(framebuffer.color) + if (framebuffer.depth) this._deleteFramebufferTexture(framebuffer.depth); + const gl = this.GL; + if (framebuffer.colorRenderbuffer) gl.deleteRenderbuffer(framebuffer.colorRenderbuffer); + if (framebuffer.depthRenderbuffer) gl.deleteRenderbuffer(framebuffer.depthRenderbuffer); + } + + deleteFramebufferResources(framebuffer) { + const gl = this.GL; + gl.deleteFramebuffer(framebuffer.framebuffer); + if (framebuffer.aaFramebuffer) { + gl.deleteFramebuffer(framebuffer.aaFramebuffer); + } + if (framebuffer.depthRenderbuffer) { + gl.deleteRenderbuffer(framebuffer.depthRenderbuffer); + } + if (framebuffer.colorRenderbuffer) { + gl.deleteRenderbuffer(framebuffer.colorRenderbuffer); + } + } + + getFramebufferToBind(framebuffer) { + if (framebuffer.antialias) { + return framebuffer.aaFramebuffer; + } else { + return framebuffer.framebuffer; + } + } + + updateFramebufferTexture(framebuffer, property) { + if (framebuffer.antialias) { + const gl = this.GL; + gl.bindFramebuffer(gl.READ_FRAMEBUFFER, framebuffer.aaFramebuffer); + gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, framebuffer.framebuffer); + const partsToCopy = { + colorTexture: [ + gl.COLOR_BUFFER_BIT, + framebuffer.colorP5Texture.magFilter === constants.LINEAR ? gl.LINEAR : gl.NEAREST + ], + }; + if (framebuffer.useDepth) { + partsToCopy.depthTexture = [ + gl.DEPTH_BUFFER_BIT, + framebuffer.depthP5Texture.magFilter === constants.LINEAR ? gl.LINEAR : gl.NEAREST + ]; + } + const [flag, filter] = partsToCopy[property]; + gl.blitFramebuffer( + 0, + 0, + framebuffer.width * framebuffer.density, + framebuffer.height * framebuffer.density, + 0, + 0, + framebuffer.width * framebuffer.density, + framebuffer.height * framebuffer.density, + flag, + filter + ); + + const activeFbo = this.activeFramebuffer(); + this.bindFramebuffer(activeFbo); + } + } + + bindFramebuffer(framebuffer) { + const gl = this.GL; + gl.bindFramebuffer( + gl.FRAMEBUFFER, + framebuffer + ? this.getFramebufferToBind(framebuffer) + : null + ); + } + + readFramebufferPixels(framebuffer) { + const gl = this.GL; + const prevFramebuffer = this.activeFramebuffer(); + gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer.framebuffer); + const colorFormat = this._getFramebufferColorFormat(framebuffer); + const pixels = readPixelsWebGL( + framebuffer.pixels, + gl, + framebuffer.framebuffer, + 0, + 0, + framebuffer.width * framebuffer.density, + framebuffer.height * framebuffer.density, + colorFormat.format, + colorFormat.type + ); + this.bindFramebuffer(prevFramebuffer); + return pixels; + } + + readFramebufferPixel(framebuffer, x, y) { + const colorFormat = this._getFramebufferColorFormat(framebuffer); + return readPixelWebGL( + this.GL, + framebuffer.framebuffer, + x, + y, + colorFormat.format, + colorFormat.type + ); + } + + readFramebufferRegion(framebuffer, x, y, w, h) { + const gl = this.GL; + const colorFormat = this._getFramebufferColorFormat(framebuffer); + + const rawData = readPixelsWebGL( + undefined, + gl, + framebuffer.framebuffer, + x * framebuffer.density, + y * framebuffer.density, + w * framebuffer.density, + h * framebuffer.density, + colorFormat.format, + colorFormat.type + ); + + // Framebuffer data might be either a Uint8Array or Float32Array + // depending on its format, and it may or may not have an alpha channel. + // To turn it into an image, we have to normalize the data into a + // Uint8ClampedArray with alpha. + const fullData = new Uint8ClampedArray( + w * h * framebuffer.density * framebuffer.density * 4 + ); + // Default channels that aren't in the framebuffer (e.g. alpha, if the + // framebuffer is in RGB mode instead of RGBA) to 255 + fullData.fill(255); + + const channels = colorFormat.format === gl.RGB ? 3 : 4; + for (let yPos = 0; yPos < h * framebuffer.density; yPos++) { + for (let xPos = 0; xPos < w * framebuffer.density; xPos++) { + for (let channel = 0; channel < 4; channel++) { + const idx = (yPos * w * framebuffer.density + xPos) * 4 + channel; + if (channel < channels) { + // Find the index of this pixel in `rawData`, which might have a + // different number of channels + const rawDataIdx = channels === 4 + ? idx + : (yPos * w * framebuffer.density + xPos) * channels + channel; + fullData[idx] = rawData[rawDataIdx]; + } + } + } + } + + // Create image from data + const region = new Image(w * framebuffer.density, h * framebuffer.density); + region.imageData = region.canvas.getContext('2d').createImageData( + region.width, + region.height + ); + region.imageData.data.set(fullData); + region.pixels = region.imageData.data; + region.updatePixels(); + if (framebuffer.density !== 1) { + region.pixelDensity(framebuffer.density); + } + return region; + } + + updateFramebufferPixels(framebuffer) { + const gl = this.GL; + framebuffer.colorP5Texture.bindTexture(); + const colorFormat = this._getFramebufferColorFormat(framebuffer); + + const channels = colorFormat.format === gl.RGBA ? 4 : 3; + const len = framebuffer.width * framebuffer.height * framebuffer.density * framebuffer.density * channels; + const TypedArrayClass = colorFormat.type === gl.UNSIGNED_BYTE ? Uint8Array : Float32Array; + + if (!(framebuffer.pixels instanceof TypedArrayClass) || framebuffer.pixels.length !== len) { + throw new Error( + 'The pixels array has not been set correctly. Please call loadPixels() before updatePixels().' + ); + } + + gl.texImage2D( + gl.TEXTURE_2D, + 0, + colorFormat.internalFormat, + framebuffer.width * framebuffer.density, + framebuffer.height * framebuffer.density, + 0, + colorFormat.format, + colorFormat.type, + framebuffer.pixels + ); + framebuffer.colorP5Texture.unbindTexture(); + + const prevFramebuffer = this.activeFramebuffer(); + if (framebuffer.antialias) { + // We need to make sure the antialiased framebuffer also has the updated + // pixels so that if more is drawn to it, it goes on top of the updated + // pixels instead of replacing them. + // We can't blit the framebuffer to the multisampled antialias + // framebuffer to leave both in the same state, so instead we have + // to use image() to put the framebuffer texture onto the antialiased + // framebuffer. + framebuffer.begin(); + this.push(); + this.states.setValue('imageMode', constants.CORNER); + this.setCamera(framebuffer.filterCamera); + this.resetMatrix(); + this.states.setValue('strokeColor', null); + this.clear(); + this._drawingFilter = true; + this.image( + framebuffer, + 0, 0, + framebuffer.width, framebuffer.height, + -this.width / 2, -this.height / 2, + this.width, this.height + ); + this._drawingFilter = false; + this.pop(); + if (framebuffer.useDepth) { + gl.clearDepth(1); + gl.clear(gl.DEPTH_BUFFER_BIT); + } + framebuffer.end(); + } else { + gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer.framebuffer); + if (framebuffer.useDepth) { + gl.clearDepth(1); + gl.clear(gl.DEPTH_BUFFER_BIT); + } + this.bindFramebuffer(prevFramebuffer); + } + } } function rendererGL(p5, fn) { diff --git a/src/webgl/p5.Texture.js b/src/webgl/p5.Texture.js index c88389bb8e..4ea07a1fba 100644 --- a/src/webgl/p5.Texture.js +++ b/src/webgl/p5.Texture.js @@ -382,27 +382,6 @@ function texture(p5, fn){ p5.MipmapTexture = MipmapTexture; } -export function checkWebGLCapabilities({ GL, webglVersion }) { - const gl = GL; - const supportsFloat = webglVersion === constants.WEBGL2 - ? (gl.getExtension('EXT_color_buffer_float') && - gl.getExtension('EXT_float_blend')) - : gl.getExtension('OES_texture_float'); - const supportsFloatLinear = supportsFloat && - gl.getExtension('OES_texture_float_linear'); - const supportsHalfFloat = webglVersion === constants.WEBGL2 - ? gl.getExtension('EXT_color_buffer_float') - : gl.getExtension('OES_texture_half_float'); - const supportsHalfFloatLinear = supportsHalfFloat && - gl.getExtension('OES_texture_half_float_linear'); - return { - float: supportsFloat, - floatLinear: supportsFloatLinear, - halfFloat: supportsHalfFloat, - halfFloatLinear: supportsHalfFloatLinear - }; -} - export default texture; export { Texture, MipmapTexture }; diff --git a/src/webgl/utils.js b/src/webgl/utils.js index 70766ac522..0727e91e1f 100644 --- a/src/webgl/utils.js +++ b/src/webgl/utils.js @@ -448,3 +448,24 @@ export function populateGLSLHooks(shader, src, shaderType) { return preMain + '\n' + defines + hooks + main + postMain; } + +export function checkWebGLCapabilities({ GL, webglVersion }) { + const gl = GL; + const supportsFloat = webglVersion === constants.WEBGL2 + ? (gl.getExtension('EXT_color_buffer_float') && + gl.getExtension('EXT_float_blend')) + : gl.getExtension('OES_texture_float'); + const supportsFloatLinear = supportsFloat && + gl.getExtension('OES_texture_float_linear'); + const supportsHalfFloat = webglVersion === constants.WEBGL2 + ? gl.getExtension('EXT_color_buffer_float') + : gl.getExtension('OES_texture_half_float'); + const supportsHalfFloatLinear = supportsHalfFloat && + gl.getExtension('OES_texture_half_float_linear'); + return { + float: supportsFloat, + floatLinear: supportsFloatLinear, + halfFloat: supportsHalfFloat, + halfFloatLinear: supportsHalfFloatLinear + }; +} diff --git a/src/webgpu/p5.RendererWebGPU.js b/src/webgpu/p5.RendererWebGPU.js index 9ded1277d2..a8732d22dd 100644 --- a/src/webgpu/p5.RendererWebGPU.js +++ b/src/webgpu/p5.RendererWebGPU.js @@ -1,6 +1,8 @@ import { Renderer3D, getStrokeDefs } from '../core/p5.Renderer3D'; import { Shader } from '../webgl/p5.Shader'; import { Texture } from '../webgl/p5.Texture'; +import { Image } from '../image/p5.Image'; +import { RGB, RGBA } from '../color/creating_reading'; import * as constants from '../core/constants'; @@ -17,6 +19,10 @@ class RendererWebGPU extends Renderer3D { this.renderPass = {}; this.samplers = new Map(); + + // Single reusable staging buffer for pixel reading + this.pixelReadBuffer = null; + this.pixelReadBufferSize = 0; } async setupContext() { @@ -1120,6 +1126,382 @@ class RendererWebGPU extends Renderer3D { return preMain + '\n' + defines + hooks + main + postMain; } + + ////////////////////////////////////////////// + // Buffer management for pixel reading + ////////////////////////////////////////////// + + _ensurePixelReadBuffer(requiredSize) { + // Create or resize staging buffer if needed + if (!this.pixelReadBuffer || this.pixelReadBufferSize < requiredSize) { + // Clean up old buffer + if (this.pixelReadBuffer) { + this.pixelReadBuffer.destroy(); + } + + // Create new buffer with padding to avoid frequent recreations + // Scale by 2 to ensure integer size and reasonable headroom + const bufferSize = Math.max(requiredSize, this.pixelReadBufferSize * 2); + this.pixelReadBuffer = this.device.createBuffer({ + size: bufferSize, + usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ, + }); + this.pixelReadBufferSize = bufferSize; + } + return this.pixelReadBuffer; + } + + ////////////////////////////////////////////// + // Framebuffer methods + ////////////////////////////////////////////// + + supportsFramebufferAntialias() { + return true; + } + + createFramebufferResources(framebuffer) { + } + + validateFramebufferFormats(framebuffer) { + if (![ + constants.UNSIGNED_BYTE, + constants.FLOAT, + constants.HALF_FLOAT + ].includes(framebuffer.format)) { + console.warn( + 'Unknown Framebuffer format. ' + + 'Please use UNSIGNED_BYTE, FLOAT, or HALF_FLOAT. ' + + 'Defaulting to UNSIGNED_BYTE.' + ); + framebuffer.format = constants.UNSIGNED_BYTE; + } + + if (framebuffer.useDepth && ![ + constants.UNSIGNED_INT, + constants.FLOAT + ].includes(framebuffer.depthFormat)) { + console.warn( + 'Unknown Framebuffer depth format. ' + + 'Please use UNSIGNED_INT or FLOAT. Defaulting to FLOAT.' + ); + framebuffer.depthFormat = constants.FLOAT; + } + } + + recreateFramebufferTextures(framebuffer) { + if (framebuffer.colorTexture && framebuffer.colorTexture.destroy) { + framebuffer.colorTexture.destroy(); + } + if (framebuffer.depthTexture && framebuffer.depthTexture.destroy) { + framebuffer.depthTexture.destroy(); + } + + const colorTextureDescriptor = { + size: { + width: framebuffer.width * framebuffer.density, + height: framebuffer.height * framebuffer.density, + depthOrArrayLayers: 1, + }, + format: this._getWebGPUColorFormat(framebuffer), + usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_SRC, + sampleCount: framebuffer.antialias ? framebuffer.antialiasSamples : 1, + }; + + framebuffer.colorTexture = this.device.createTexture(colorTextureDescriptor); + + if (framebuffer.useDepth) { + const depthTextureDescriptor = { + size: { + width: framebuffer.width * framebuffer.density, + height: framebuffer.height * framebuffer.density, + depthOrArrayLayers: 1, + }, + format: this._getWebGPUDepthFormat(framebuffer), + usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING, + sampleCount: framebuffer.antialias ? framebuffer.antialiasSamples : 1, + }; + + framebuffer.depthTexture = this.device.createTexture(depthTextureDescriptor); + } + } + + _getWebGPUColorFormat(framebuffer) { + if (framebuffer.format === constants.FLOAT) { + return framebuffer.channels === RGBA ? 'rgba32float' : 'rgba32float'; + } else if (framebuffer.format === constants.HALF_FLOAT) { + return framebuffer.channels === RGBA ? 'rgba16float' : 'rgba16float'; + } else { + return framebuffer.channels === RGBA ? 'rgba8unorm' : 'rgba8unorm'; + } + } + + _getWebGPUDepthFormat(framebuffer) { + if (framebuffer.useStencil) { + return framebuffer.depthFormat === constants.FLOAT ? 'depth32float-stencil8' : 'depth24plus-stencil8'; + } else { + return framebuffer.depthFormat === constants.FLOAT ? 'depth32float' : 'depth24plus'; + } + } + + _deleteFramebufferTexture(texture) { + const handle = texture.rawTexture(); + if (handle.texture && handle.texture.destroy) { + handle.texture.destroy(); + } + this.textures.delete(texture); + } + + deleteFramebufferTextures(framebuffer) { + this._deleteFramebufferTexture(framebuffer.color) + if (framebuffer.depth) this._deleteFramebufferTexture(framebuffer.depth); + } + + deleteFramebufferResources(framebuffer) { + if (framebuffer.colorTexture && framebuffer.colorTexture.destroy) { + framebuffer.colorTexture.destroy(); + } + if (framebuffer.depthTexture && framebuffer.depthTexture.destroy) { + framebuffer.depthTexture.destroy(); + } + } + + getFramebufferToBind(framebuffer) { + } + + updateFramebufferTexture(framebuffer, property) { + // No-op for WebGPU since antialiasing is handled at pipeline level + } + + bindFramebuffer(framebuffer) {} + + async readFramebufferPixels(framebuffer) { + const width = framebuffer.width * framebuffer.density; + const height = framebuffer.height * framebuffer.density; + const bytesPerPixel = 4; + const bufferSize = width * height * bytesPerPixel; + + const stagingBuffer = this._ensurePixelReadBuffer(bufferSize); + + const commandEncoder = this.device.createCommandEncoder(); + commandEncoder.copyTextureToBuffer( + { texture: framebuffer.colorTexture }, + { buffer: stagingBuffer, bytesPerRow: width * bytesPerPixel }, + { width, height, depthOrArrayLayers: 1 } + ); + + this.device.queue.submit([commandEncoder.finish()]); + + await stagingBuffer.mapAsync(GPUMapMode.READ, 0, bufferSize); + const mappedRange = stagingBuffer.getMappedRange(0, bufferSize); + const result = new Uint8Array(mappedRange.slice(0, bufferSize)); + + stagingBuffer.unmap(); + return result; + } + + async readFramebufferPixel(framebuffer, x, y) { + const bytesPerPixel = 4; + const stagingBuffer = this._ensurePixelReadBuffer(bytesPerPixel); + + const commandEncoder = this.device.createCommandEncoder(); + commandEncoder.copyTextureToBuffer( + { + texture: framebuffer.colorTexture, + origin: { x, y, z: 0 } + }, + { buffer: stagingBuffer, bytesPerRow: bytesPerPixel }, + { width: 1, height: 1, depthOrArrayLayers: 1 } + ); + + this.device.queue.submit([commandEncoder.finish()]); + + await stagingBuffer.mapAsync(GPUMapMode.READ, 0, bytesPerPixel); + const mappedRange = stagingBuffer.getMappedRange(0, bytesPerPixel); + const pixelData = new Uint8Array(mappedRange); + const result = [pixelData[0], pixelData[1], pixelData[2], pixelData[3]]; + + stagingBuffer.unmap(); + return result; + } + + async readFramebufferRegion(framebuffer, x, y, w, h) { + const width = w * framebuffer.density; + const height = h * framebuffer.density; + const bytesPerPixel = 4; + const bufferSize = width * height * bytesPerPixel; + + const stagingBuffer = this._ensurePixelReadBuffer(bufferSize); + + const commandEncoder = this.device.createCommandEncoder(); + commandEncoder.copyTextureToBuffer( + { + texture: framebuffer.colorTexture, + origin: { x: x * framebuffer.density, y: y * framebuffer.density, z: 0 } + }, + { buffer: stagingBuffer, bytesPerRow: width * bytesPerPixel }, + { width, height, depthOrArrayLayers: 1 } + ); + + this.device.queue.submit([commandEncoder.finish()]); + + await stagingBuffer.mapAsync(GPUMapMode.READ, 0, bufferSize); + const mappedRange = stagingBuffer.getMappedRange(0, bufferSize); + const pixelData = new Uint8Array(mappedRange.slice(0, bufferSize)); + + // WebGPU doesn't need vertical flipping unlike WebGL + const region = new Image(width, height); + region.imageData = region.canvas.getContext('2d').createImageData(width, height); + region.imageData.data.set(pixelData); + region.pixels = region.imageData.data; + region.updatePixels(); + + if (framebuffer.density !== 1) { + region.pixelDensity(framebuffer.density); + } + + stagingBuffer.unmap(); + return region; + } + + updateFramebufferPixels(framebuffer) { + const width = framebuffer.width * framebuffer.density; + const height = framebuffer.height * framebuffer.density; + const bytesPerPixel = 4; + + const expectedLength = width * height * bytesPerPixel; + if (!framebuffer.pixels || framebuffer.pixels.length !== expectedLength) { + throw new Error( + 'The pixels array has not been set correctly. Please call loadPixels() before updatePixels().' + ); + } + + this.device.queue.writeTexture( + { texture: framebuffer.colorTexture }, + framebuffer.pixels, + { + bytesPerRow: width * bytesPerPixel, + rowsPerImage: height + }, + { width, height, depthOrArrayLayers: 1 } + ); + } + + ////////////////////////////////////////////// + // Main canvas pixel methods + ////////////////////////////////////////////// + + async loadPixels() { + const width = this.width * this._pixelDensity; + const height = this.height * this._pixelDensity; + const bytesPerPixel = 4; + const bufferSize = width * height * bytesPerPixel; + + const stagingBuffer = this._ensurePixelReadBuffer(bufferSize); + + // Get the current canvas texture + const canvasTexture = this.drawingContext.getCurrentTexture(); + + const commandEncoder = this.device.createCommandEncoder(); + commandEncoder.copyTextureToBuffer( + { texture: canvasTexture }, + { buffer: stagingBuffer, bytesPerRow: width * bytesPerPixel }, + { width, height, depthOrArrayLayers: 1 } + ); + + this.device.queue.submit([commandEncoder.finish()]); + + await stagingBuffer.mapAsync(GPUMapMode.READ, 0, bufferSize); + const mappedRange = stagingBuffer.getMappedRange(0, bufferSize); + this.pixels = new Uint8Array(mappedRange.slice(0, bufferSize)); + + stagingBuffer.unmap(); + return this.pixels; + } + + async _getPixel(x, y) { + const bytesPerPixel = 4; + const stagingBuffer = this._ensurePixelReadBuffer(bytesPerPixel); + + const canvasTexture = this.drawingContext.getCurrentTexture(); + const commandEncoder = this.device.createCommandEncoder(); + commandEncoder.copyTextureToBuffer( + { + texture: canvasTexture, + origin: { x, y, z: 0 } + }, + { buffer: stagingBuffer, bytesPerRow: bytesPerPixel }, + { width: 1, height: 1, depthOrArrayLayers: 1 } + ); + + this.device.queue.submit([commandEncoder.finish()]); + + await stagingBuffer.mapAsync(GPUMapMode.READ, 0, bytesPerPixel); + const mappedRange = stagingBuffer.getMappedRange(0, bytesPerPixel); + const pixelData = new Uint8Array(mappedRange); + const result = [pixelData[0], pixelData[1], pixelData[2], pixelData[3]]; + + stagingBuffer.unmap(); + return result; + } + + async get(x, y, w, h) { + const pd = this._pixelDensity; + + if (typeof x === 'undefined' && typeof y === 'undefined') { + // get() - return entire canvas + x = y = 0; + w = this.width; + h = this.height; + } else { + x *= pd; + y *= pd; + + if (typeof w === 'undefined' && typeof h === 'undefined') { + // get(x,y) - single pixel + if (x < 0 || y < 0 || x >= this.width * pd || y >= this.height * pd) { + return [0, 0, 0, 0]; + } + + return this._getPixel(x, y); + } + // get(x,y,w,h) - region + } + + // Read region and create p5.Image + const width = w * pd; + const height = h * pd; + const bytesPerPixel = 4; + const bufferSize = width * height * bytesPerPixel; + + const stagingBuffer = this._ensurePixelReadBuffer(bufferSize); + + const canvasTexture = this.drawingContext.getCurrentTexture(); + const commandEncoder = this.device.createCommandEncoder(); + commandEncoder.copyTextureToBuffer( + { + texture: canvasTexture, + origin: { x, y, z: 0 } + }, + { buffer: stagingBuffer, bytesPerRow: width * bytesPerPixel }, + { width, height, depthOrArrayLayers: 1 } + ); + + this.device.queue.submit([commandEncoder.finish()]); + + await stagingBuffer.mapAsync(GPUMapMode.READ, 0, bufferSize); + const mappedRange = stagingBuffer.getMappedRange(0, bufferSize); + const pixelData = new Uint8Array(mappedRange.slice(0, bufferSize)); + + const region = new Image(width, height); + region.pixelDensity(pd); + region.imageData = region.canvas.getContext('2d').createImageData(width, height); + region.imageData.data.set(pixelData); + region.pixels = region.imageData.data; + region.updatePixels(); + + stagingBuffer.unmap(); + return region; + } } function rendererWebGPU(p5, fn) { diff --git a/test/unit/visual/cases/webgpu.js b/test/unit/visual/cases/webgpu.js index efd8cc7e93..363626807a 100644 --- a/test/unit/visual/cases/webgpu.js +++ b/test/unit/visual/cases/webgpu.js @@ -103,4 +103,168 @@ visualSuite('WebGPU', function() { screenshot(); }); }); + + visualSuite('Framebuffers', function() { + visualTest('Basic framebuffer draw to canvas', async function(p5, screenshot) { + await p5.createCanvas(50, 50, p5.WEBGPU); + + // Create a framebuffer + const fbo = p5.createFramebuffer({ width: 25, height: 25 }); + + // Draw to the framebuffer + fbo.draw(() => { + p5.background(255, 0, 0); // Red background + p5.fill(0, 255, 0); // Green circle + p5.noStroke(); + p5.circle(12.5, 12.5, 20); + }); + + // Draw the framebuffer to the main canvas + p5.background(0, 0, 255); // Blue background + p5.texture(fbo); + p5.noStroke(); + p5.plane(25, 25); + + screenshot(); + }); + + visualTest('Framebuffer with different sizes', async function(p5, screenshot) { + await p5.createCanvas(50, 50, p5.WEBGPU); + + // Create two different sized framebuffers + const fbo1 = p5.createFramebuffer({ width: 20, height: 20 }); + const fbo2 = p5.createFramebuffer({ width: 15, height: 15 }); + + // Draw to first framebuffer + fbo1.draw(() => { + p5.background(255, 100, 100); + p5.fill(255, 255, 0); + p5.noStroke(); + p5.rect(5, 5, 10, 10); + }); + + // Draw to second framebuffer + fbo2.draw(() => { + p5.background(100, 255, 100); + p5.fill(255, 0, 255); + p5.noStroke(); + p5.circle(7.5, 7.5, 10); + }); + + // Draw both to main canvas + p5.background(50); + p5.push(); + p5.translate(-12.5, -12.5); + p5.texture(fbo1); + p5.noStroke(); + p5.plane(20, 20); + p5.pop(); + + p5.push(); + p5.translate(12.5, 12.5); + p5.texture(fbo2); + p5.noStroke(); + p5.plane(15, 15); + p5.pop(); + + screenshot(); + }); + + visualTest('Auto-sized framebuffer', async function(p5, screenshot) { + await p5.createCanvas(50, 50, p5.WEBGPU); + + // Create auto-sized framebuffer (should match canvas size) + const fbo = p5.createFramebuffer(); + + // Draw to the framebuffer + fbo.draw(() => { + p5.background(0); + p5.stroke(255); + p5.strokeWeight(2); + p5.noFill(); + // Draw a grid pattern to verify size + for (let x = 0; x < 50; x += 10) { + p5.line(x, 0, x, 50); + } + for (let y = 0; y < 50; y += 10) { + p5.line(0, y, 50, y); + } + p5.fill(255, 0, 0); + p5.noStroke(); + p5.circle(25, 25, 15); + }); + + // Draw the framebuffer to fill the main canvas + p5.texture(fbo); + p5.noStroke(); + p5.plane(50, 50); + + screenshot(); + }); + + visualTest('Auto-sized framebuffer after canvas resize', async function(p5, screenshot) { + await p5.createCanvas(50, 50, p5.WEBGPU); + + // Create auto-sized framebuffer + const fbo = p5.createFramebuffer(); + + // Resize the canvas (framebuffer should auto-resize) + p5.resizeCanvas(30, 30); + + // Draw to the framebuffer after resize + fbo.draw(() => { + p5.background(100, 0, 100); + p5.fill(0, 255, 255); + p5.noStroke(); + // Draw a shape that fills the new size + p5.rect(5, 5, 20, 20); + p5.fill(255, 255, 0); + p5.circle(15, 15, 10); + }); + + // Draw the framebuffer to the main canvas + p5.texture(fbo); + p5.noStroke(); + p5.plane(30, 30); + + screenshot(); + }); + + visualTest('Fixed-size framebuffer after manual resize', async function(p5, screenshot) { + await p5.createCanvas(50, 50, p5.WEBGPU); + + // Create fixed-size framebuffer + const fbo = p5.createFramebuffer({ width: 20, height: 20 }); + + // Draw initial content + fbo.draw(() => { + p5.background(255, 200, 100); + p5.fill(0, 100, 200); + p5.noStroke(); + p5.circle(10, 10, 15); + }); + + // Manually resize the framebuffer + fbo.resize(35, 25); + + // Draw new content to the resized framebuffer + fbo.draw(() => { + p5.background(200, 255, 100); + p5.fill(200, 0, 100); + p5.noStroke(); + // Draw content that uses the new size + p5.rect(5, 5, 25, 15); + p5.fill(0, 0, 255); + p5.circle(17.5, 12.5, 8); + }); + + // Draw the resized framebuffer to the main canvas + p5.background(50); + p5.texture(fbo); + p5.noStroke(); + p5.plane(35, 25); + + screenshot(); + }); + }); }); diff --git a/test/unit/webgl/p5.RendererGL.js b/test/unit/webgl/p5.RendererGL.js index 34b64abdfd..f437ac4c20 100644 --- a/test/unit/webgl/p5.RendererGL.js +++ b/test/unit/webgl/p5.RendererGL.js @@ -1098,7 +1098,7 @@ suite('p5.RendererGL', function() { assert.isTrue(img.length === 4); }); - test('updatePixels() matches 2D mode', function() { + test.only('updatePixels() matches 2D mode', function() { myp5.createCanvas(20, 20); myp5.pixelDensity(1); const getColors = function(mode) { @@ -1120,6 +1120,7 @@ suite('p5.RendererGL', function() { }; const p2d = getColors(myp5.P2D); + debugger const webgl = getColors(myp5.WEBGL); myp5.image(p2d, 0, 0); myp5.blendMode(myp5.DIFFERENCE); diff --git a/test/unit/webgpu/p5.Framebuffer.js b/test/unit/webgpu/p5.Framebuffer.js new file mode 100644 index 0000000000..9fec2f070d --- /dev/null +++ b/test/unit/webgpu/p5.Framebuffer.js @@ -0,0 +1,247 @@ +import p5 from '../../../src/app.js'; + +suite('WebGPU p5.Framebuffer', function() { + let myp5; + let prevPixelRatio; + + beforeAll(async function() { + prevPixelRatio = window.devicePixelRatio; + window.devicePixelRatio = 1; + myp5 = new p5(function(p) { + p.setup = function() {}; + p.draw = function() {}; + }); + }); + + afterAll(function() { + myp5.remove(); + window.devicePixelRatio = prevPixelRatio; + }); + + suite('Creation and basic properties', function() { + test('framebuffers can be created with WebGPU renderer', async function() { + await myp5.createCanvas(10, 10, myp5.WEBGPU); + const fbo = myp5.createFramebuffer(); + + expect(fbo).to.be.an('object'); + expect(fbo.width).to.equal(10); + expect(fbo.height).to.equal(10); + expect(fbo.autoSized()).to.equal(true); + }); + + test('framebuffers can be created with custom dimensions', async function() { + await myp5.createCanvas(10, 10, myp5.WEBGPU); + const fbo = myp5.createFramebuffer({ width: 20, height: 30 }); + + expect(fbo.width).to.equal(20); + expect(fbo.height).to.equal(30); + expect(fbo.autoSized()).to.equal(false); + }); + + test('framebuffers have color texture', async function() { + await myp5.createCanvas(10, 10, myp5.WEBGPU); + const fbo = myp5.createFramebuffer(); + + expect(fbo.color).to.be.an('object'); + expect(fbo.color.rawTexture).to.be.a('function'); + }); + + test('framebuffers can specify different formats', async function() { + await myp5.createCanvas(10, 10, myp5.WEBGPU); + const fbo = myp5.createFramebuffer({ + format: 'float', + channels: 'rgb' + }); + + expect(fbo).to.be.an('object'); + expect(fbo.width).to.equal(10); + expect(fbo.height).to.equal(10); + }); + }); + + suite('Auto-sizing behavior', function() { + test('auto-sized framebuffers change size with canvas', async function() { + await myp5.createCanvas(10, 10, myp5.WEBGPU); + myp5.pixelDensity(1); + const fbo = myp5.createFramebuffer(); + + expect(fbo.autoSized()).to.equal(true); + expect(fbo.width).to.equal(10); + expect(fbo.height).to.equal(10); + expect(fbo.density).to.equal(1); + + myp5.resizeCanvas(15, 20); + myp5.pixelDensity(2); + expect(fbo.width).to.equal(15); + expect(fbo.height).to.equal(20); + expect(fbo.density).to.equal(2); + }); + + test('manually-sized framebuffers do not change size with canvas', async function() { + await myp5.createCanvas(10, 10, myp5.WEBGPU); + myp5.pixelDensity(3); + const fbo = myp5.createFramebuffer({ width: 25, height: 30, density: 1 }); + + expect(fbo.autoSized()).to.equal(false); + expect(fbo.width).to.equal(25); + expect(fbo.height).to.equal(30); + expect(fbo.density).to.equal(1); + + myp5.resizeCanvas(5, 15); + myp5.pixelDensity(2); + expect(fbo.width).to.equal(25); + expect(fbo.height).to.equal(30); + expect(fbo.density).to.equal(1); + }); + + test('manually-sized framebuffers can be made auto-sized', async function() { + await myp5.createCanvas(10, 10, myp5.WEBGPU); + myp5.pixelDensity(1); + const fbo = myp5.createFramebuffer({ width: 25, height: 30, density: 2 }); + + expect(fbo.autoSized()).to.equal(false); + expect(fbo.width).to.equal(25); + expect(fbo.height).to.equal(30); + expect(fbo.density).to.equal(2); + + // Make it auto-sized + fbo.autoSized(true); + expect(fbo.autoSized()).to.equal(true); + + myp5.resizeCanvas(8, 12); + myp5.pixelDensity(3); + expect(fbo.width).to.equal(8); + expect(fbo.height).to.equal(12); + expect(fbo.density).to.equal(3); + }); + }); + + suite('Manual resizing', function() { + test('framebuffers can be manually resized', async function() { + await myp5.createCanvas(10, 10, myp5.WEBGPU); + myp5.pixelDensity(1); + const fbo = myp5.createFramebuffer(); + + expect(fbo.width).to.equal(10); + expect(fbo.height).to.equal(10); + expect(fbo.density).to.equal(1); + + fbo.resize(20, 25); + expect(fbo.width).to.equal(20); + expect(fbo.height).to.equal(25); + expect(fbo.autoSized()).to.equal(false); + }); + + test('resizing affects pixel density', async function() { + await myp5.createCanvas(10, 10, myp5.WEBGPU); + myp5.pixelDensity(1); + const fbo = myp5.createFramebuffer(); + + fbo.pixelDensity(3); + expect(fbo.density).to.equal(3); + + fbo.resize(15, 20); + fbo.pixelDensity(2); + expect(fbo.width).to.equal(15); + expect(fbo.height).to.equal(20); + expect(fbo.density).to.equal(2); + }); + }); + + suite('Drawing functionality', function() { + test('can draw to framebuffer with draw() method', async function() { + await myp5.createCanvas(10, 10, myp5.WEBGPU); + const fbo = myp5.createFramebuffer(); + + let drawCallbackExecuted = false; + fbo.draw(() => { + drawCallbackExecuted = true; + myp5.background(255, 0, 0); + myp5.fill(0, 255, 0); + myp5.noStroke(); + myp5.circle(5, 5, 8); + }); + + expect(drawCallbackExecuted).to.equal(true); + }); + + test('can use framebuffer as texture', async function() { + await myp5.createCanvas(10, 10, myp5.WEBGPU); + const fbo = myp5.createFramebuffer(); + + fbo.draw(() => { + myp5.background(255, 0, 0); + }); + + // Should not throw when used as texture + expect(() => { + myp5.texture(fbo); + myp5.plane(10, 10); + }).to.not.throw(); + }); + }); + + suite('Pixel access', function() { + test('loadPixels returns a promise in WebGPU', async function() { + await myp5.createCanvas(10, 10, myp5.WEBGPU); + const fbo = myp5.createFramebuffer(); + + fbo.draw(() => { + myp5.background(255, 0, 0); + }); + + const result = fbo.loadPixels(); + expect(result).to.be.a('promise'); + + const pixels = await result; + expect(pixels).to.be.an('array'); + expect(pixels.length).to.equal(10 * 10 * 4); + }); + + test('pixels property is set after loadPixels resolves', async function() { + await myp5.createCanvas(10, 10, myp5.WEBGPU); + const fbo = myp5.createFramebuffer(); + + fbo.draw(() => { + myp5.background(100, 150, 200); + }); + + const pixels = await fbo.loadPixels(); + expect(fbo.pixels).to.equal(pixels); + expect(fbo.pixels.length).to.equal(10 * 10 * 4); + }); + + test('get() returns a promise for single pixel in WebGPU', async function() { + await myp5.createCanvas(10, 10, myp5.WEBGPU); + const fbo = myp5.createFramebuffer(); + + fbo.draw(() => { + myp5.background(100, 150, 200); + }); + + const result = fbo.get(5, 5); + expect(result).to.be.a('promise'); + + const color = await result; + expect(color).to.be.an('array'); + expect(color).to.have.length(4); + }); + + test('get() returns a promise for region in WebGPU', async function() { + await myp5.createCanvas(10, 10, myp5.WEBGPU); + const fbo = myp5.createFramebuffer(); + + fbo.draw(() => { + myp5.background(100, 150, 200); + }); + + const result = fbo.get(2, 2, 4, 4); + expect(result).to.be.a('promise'); + + const region = await result; + expect(region).to.be.an('object'); // Should be a p5.Image + expect(region.width).to.equal(4); + expect(region.height).to.equal(4); + }); + }); +}); From f94f8981f051cfdc3299058500e48e8b1b59d2a3 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Mon, 28 Jul 2025 11:15:28 -0400 Subject: [PATCH 34/72] Fix ordering of dirty flag --- src/webgl/p5.Framebuffer.js | 1 - src/webgl/p5.RendererGL.js | 1 + test/unit/webgl/p5.RendererGL.js | 3 +-- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/webgl/p5.Framebuffer.js b/src/webgl/p5.Framebuffer.js index af2ab279b5..297e3dd09f 100644 --- a/src/webgl/p5.Framebuffer.js +++ b/src/webgl/p5.Framebuffer.js @@ -1170,7 +1170,6 @@ class Framebuffer { updatePixels() { // Let renderer handle the pixel update process this.renderer.updateFramebufferPixels(this); - this.dirty.colorTexture = false; } } diff --git a/src/webgl/p5.RendererGL.js b/src/webgl/p5.RendererGL.js index 5a9482baa2..507839e1fe 100644 --- a/src/webgl/p5.RendererGL.js +++ b/src/webgl/p5.RendererGL.js @@ -1940,6 +1940,7 @@ class RendererGL extends Renderer3D { framebuffer.pixels ); framebuffer.colorP5Texture.unbindTexture(); + framebuffer.dirty.colorTexture = false; const prevFramebuffer = this.activeFramebuffer(); if (framebuffer.antialias) { diff --git a/test/unit/webgl/p5.RendererGL.js b/test/unit/webgl/p5.RendererGL.js index f437ac4c20..34b64abdfd 100644 --- a/test/unit/webgl/p5.RendererGL.js +++ b/test/unit/webgl/p5.RendererGL.js @@ -1098,7 +1098,7 @@ suite('p5.RendererGL', function() { assert.isTrue(img.length === 4); }); - test.only('updatePixels() matches 2D mode', function() { + test('updatePixels() matches 2D mode', function() { myp5.createCanvas(20, 20); myp5.pixelDensity(1); const getColors = function(mode) { @@ -1120,7 +1120,6 @@ suite('p5.RendererGL', function() { }; const p2d = getColors(myp5.P2D); - debugger const webgl = getColors(myp5.WEBGL); myp5.image(p2d, 0, 0); myp5.blendMode(myp5.DIFFERENCE); From 5dfcd2456eee86e63ae2b8332b9c363bcab1dbd6 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Mon, 28 Jul 2025 17:04:02 -0400 Subject: [PATCH 35/72] Make sure textures are cleared at start --- preview/index.html | 7 +- src/webgl/p5.Framebuffer.js | 4 +- src/webgl/p5.RendererGL.js | 15 +++ src/webgl/p5.Texture.js | 2 + src/webgpu/p5.RendererWebGPU.js | 218 +++++++++++++++++++++++++++---- test/unit/visual/cases/webgpu.js | 72 +++++----- test/unit/visual/visualTest.js | 94 ++++++------- 7 files changed, 304 insertions(+), 108 deletions(-) diff --git a/preview/index.html b/preview/index.html index 6e4915ab34..4092992316 100644 --- a/preview/index.html +++ b/preview/index.html @@ -30,6 +30,7 @@ p.setup = async function () { await p.createCanvas(400, 400, p.WEBGPU); + fbo = p.createFramebuffer(); tex = p.createImage(100, 100); tex.loadPixels(); @@ -43,6 +44,10 @@ } } tex.updatePixels(); + fbo.draw(() => { + p.imageMode(p.CENTER); + p.image(tex, 0, 0, p.width, p.height); + }); sh = p.baseMaterialShader().modify({ uniforms: { @@ -87,7 +92,7 @@ 0, //p.width/3 * p.sin(t * 0.9 + i * Math.E + 0.2), p.width/3 * p.sin(t * 1.2 + i * Math.E + 0.3), ) - p.texture(tex) + p.texture(fbo) p.sphere(30); p.pop(); } diff --git a/src/webgl/p5.Framebuffer.js b/src/webgl/p5.Framebuffer.js index 297e3dd09f..0fb5504d25 100644 --- a/src/webgl/p5.Framebuffer.js +++ b/src/webgl/p5.Framebuffer.js @@ -67,7 +67,7 @@ class Framebuffer { this.format = settings.format || constants.UNSIGNED_BYTE; this.channels = settings.channels || ( - this.renderer._pInst._glAttributes.alpha + this.renderer.defaultFramebufferAlpha() ? RGBA : RGB ); @@ -75,7 +75,7 @@ class Framebuffer { this.depthFormat = settings.depthFormat || constants.FLOAT; this.textureFiltering = settings.textureFiltering || constants.LINEAR; if (settings.antialias === undefined) { - this.antialiasSamples = this.renderer._pInst._glAttributes.antialias + this.antialiasSamples = this.renderer.defaultFramebufferAntialias() ? 2 : 0; } else if (typeof settings.antialias === 'number') { diff --git a/src/webgl/p5.RendererGL.js b/src/webgl/p5.RendererGL.js index 507839e1fe..c6fbfa45a6 100644 --- a/src/webgl/p5.RendererGL.js +++ b/src/webgl/p5.RendererGL.js @@ -1302,6 +1302,11 @@ class RendererGL extends Renderer3D { return { texture: tex, glFormat: gl.RGBA, glDataType: gl.UNSIGNED_BYTE }; } + createFramebufferTextureHandle(framebufferTexture) { + // For WebGL, framebuffer texture handles are designed to be null + return null; + } + uploadTextureFromSource({ texture, glFormat, glDataType }, source) { const gl = this.GL; gl.texImage2D(gl.TEXTURE_2D, 0, glFormat, glFormat, glDataType, source); @@ -1394,6 +1399,16 @@ class RendererGL extends Renderer3D { // Framebuffer methods ////////////////////////////////////////////// + defaultFramebufferAlpha() { + return this._pInst._glAttributes.alpha; + } + + defaultFramebufferAntialias() { + return this.supportsFramebufferAntialias() + ? this._pInst._glAttributes.antialias + : false; + } + supportsFramebufferAntialias() { return this.webglVersion === constants.WEBGL2; } diff --git a/src/webgl/p5.Texture.js b/src/webgl/p5.Texture.js index 4ea07a1fba..d1c45b84f1 100644 --- a/src/webgl/p5.Texture.js +++ b/src/webgl/p5.Texture.js @@ -128,6 +128,8 @@ class Texture { width: textureData.width, height: textureData.height, }); + } else { + this.textureHandle = this._renderer.createFramebufferTextureHandle(this.src); } this._renderer.setTextureParams(this, { diff --git a/src/webgpu/p5.RendererWebGPU.js b/src/webgpu/p5.RendererWebGPU.js index a8732d22dd..29ffcbf40d 100644 --- a/src/webgpu/p5.RendererWebGPU.js +++ b/src/webgpu/p5.RendererWebGPU.js @@ -27,7 +27,10 @@ class RendererWebGPU extends Renderer3D { async setupContext() { this.adapter = await navigator.gpu?.requestAdapter(); - this.device = await this.adapter?.requestDevice(); + this.device = await this.adapter?.requestDevice({ + // Todo: check support + requiredFeatures: ['depth32float-stencil8'] + }); if (!this.device) { throw new Error('Your browser does not support WebGPU.'); } @@ -36,7 +39,8 @@ class RendererWebGPU extends Renderer3D { this.presentationFormat = navigator.gpu.getPreferredCanvasFormat(); this.drawingContext.configure({ device: this.device, - format: this.presentationFormat + format: this.presentationFormat, + usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC, }); // TODO disablable stencil @@ -193,12 +197,34 @@ class RendererWebGPU extends Renderer3D { freeDefs(this.renderer.buffers.user); } + _getValidSampleCount(requestedCount) { + // WebGPU supports sample counts of 1, 4 (and sometimes 8) + if (requestedCount <= 1) return 1; + if (requestedCount <= 4) return 4; + return 4; // Cap at 4 for broader compatibility + } + _shaderOptions({ mode }) { + const activeFramebuffer = this.activeFramebuffer(); + const format = activeFramebuffer ? + this._getWebGPUColorFormat(activeFramebuffer) : + this.presentationFormat; + + const requestedSampleCount = activeFramebuffer ? + (activeFramebuffer.antialias ? activeFramebuffer.antialiasSamples : 1) : + (this.antialias || 1); + const sampleCount = this._getValidSampleCount(requestedSampleCount); + + const depthFormat = activeFramebuffer && activeFramebuffer.useDepth ? + this._getWebGPUDepthFormat(activeFramebuffer) : + this.depthFormat; + return { topology: mode === constants.TRIANGLE_STRIP ? 'triangle-strip' : 'triangle-list', blendMode: this.states.curBlendMode, - sampleCount: (this.activeFramebuffer() || this).antialias || 1, // TODO - format: this.activeFramebuffer()?.format || this.presentationFormat, // TODO + sampleCount, + format, + depthFormat, } } @@ -209,8 +235,8 @@ class RendererWebGPU extends Renderer3D { shader.fragModule = device.createShaderModule({ code: shader.fragSrc() }); shader._pipelineCache = new Map(); - shader.getPipeline = ({ topology, blendMode, sampleCount, format }) => { - const key = `${topology}_${blendMode}_${sampleCount}_${format}`; + shader.getPipeline = ({ topology, blendMode, sampleCount, format, depthFormat }) => { + const key = `${topology}_${blendMode}_${sampleCount}_${format}_${depthFormat}`; if (!shader._pipelineCache.has(key)) { const pipeline = device.createRenderPipeline({ layout: shader._pipelineLayout, @@ -230,7 +256,7 @@ class RendererWebGPU extends Renderer3D { primitive: { topology }, multisample: { count: sampleCount }, depthStencil: { - format: this.depthFormat, + format: depthFormat, depthWriteEnabled: true, depthCompare: 'less', stencilFront: { @@ -531,9 +557,15 @@ class RendererWebGPU extends Renderer3D { _useShader(shader, options) {} _updateViewport() { + this._origViewport = { + width: this.width, + height: this.height, + }; this._viewport = [0, 0, this.width, this.height]; } + viewport() {} + zClipRange() { return [0, 1]; } @@ -573,14 +605,27 @@ class RendererWebGPU extends Renderer3D { if (!buffers) return; const commandEncoder = this.device.createCommandEncoder(); - const currentTexture = this.drawingContext.getCurrentTexture(); + + // Use framebuffer texture if active, otherwise use canvas texture + const activeFramebuffer = this.activeFramebuffer(); + const colorTexture = activeFramebuffer ? + (activeFramebuffer.aaColorTexture || activeFramebuffer.colorTexture) : + this.drawingContext.getCurrentTexture(); + const colorAttachment = { - view: currentTexture.createView(), + view: colorTexture.createView(), loadOp: "load", storeOp: "store", + // If using multisampled texture, resolve to non-multisampled texture + resolveTarget: activeFramebuffer && activeFramebuffer.aaColorTexture ? + activeFramebuffer.colorTexture.createView() : undefined, }; - const depthTextureView = this.depthTexture?.createView(); + // Use framebuffer depth texture if active, otherwise use canvas depth texture + const depthTexture = activeFramebuffer ? + (activeFramebuffer.aaDepthTexture || activeFramebuffer.depthTexture) : + this.depthTexture; + const depthTextureView = depthTexture?.createView(); const renderPassDescriptor = { colorAttachments: [colorAttachment], depthStencilAttachment: depthTextureView @@ -1155,6 +1200,14 @@ class RendererWebGPU extends Renderer3D { // Framebuffer methods ////////////////////////////////////////////// + defaultFramebufferAlpha() { + return true + } + + defaultFramebufferAntialias() { + return true; + } + supportsFramebufferAntialias() { return true; } @@ -1189,40 +1242,138 @@ class RendererWebGPU extends Renderer3D { } recreateFramebufferTextures(framebuffer) { + // Clean up existing textures if (framebuffer.colorTexture && framebuffer.colorTexture.destroy) { framebuffer.colorTexture.destroy(); } + if (framebuffer.aaColorTexture && framebuffer.aaColorTexture.destroy) { + framebuffer.aaColorTexture.destroy(); + } if (framebuffer.depthTexture && framebuffer.depthTexture.destroy) { framebuffer.depthTexture.destroy(); } + if (framebuffer.aaDepthTexture && framebuffer.aaDepthTexture.destroy) { + framebuffer.aaDepthTexture.destroy(); + } + // Clear cached views when recreating textures + framebuffer._colorTextureView = null; - const colorTextureDescriptor = { + const baseDescriptor = { size: { width: framebuffer.width * framebuffer.density, height: framebuffer.height * framebuffer.density, depthOrArrayLayers: 1, }, format: this._getWebGPUColorFormat(framebuffer), - usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_SRC, - sampleCount: framebuffer.antialias ? framebuffer.antialiasSamples : 1, }; + // Create non-multisampled texture for texture binding (always needed) + const colorTextureDescriptor = { + ...baseDescriptor, + usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_SRC, + sampleCount: 1, + }; framebuffer.colorTexture = this.device.createTexture(colorTextureDescriptor); + // Create multisampled texture for rendering if antialiasing is enabled + if (framebuffer.antialias) { + const aaColorTextureDescriptor = { + ...baseDescriptor, + usage: GPUTextureUsage.RENDER_ATTACHMENT, + sampleCount: this._getValidSampleCount(framebuffer.antialiasSamples), + }; + framebuffer.aaColorTexture = this.device.createTexture(aaColorTextureDescriptor); + } + if (framebuffer.useDepth) { - const depthTextureDescriptor = { + const depthBaseDescriptor = { size: { width: framebuffer.width * framebuffer.density, height: framebuffer.height * framebuffer.density, depthOrArrayLayers: 1, }, format: this._getWebGPUDepthFormat(framebuffer), - usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING, - sampleCount: framebuffer.antialias ? framebuffer.antialiasSamples : 1, }; + // Create non-multisampled depth texture for texture binding (always needed) + const depthTextureDescriptor = { + ...depthBaseDescriptor, + usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING, + sampleCount: 1, + }; framebuffer.depthTexture = this.device.createTexture(depthTextureDescriptor); + + // Create multisampled depth texture for rendering if antialiasing is enabled + if (framebuffer.antialias) { + const aaDepthTextureDescriptor = { + ...depthBaseDescriptor, + usage: GPUTextureUsage.RENDER_ATTACHMENT, + sampleCount: this._getValidSampleCount(framebuffer.antialiasSamples), + }; + framebuffer.aaDepthTexture = this.device.createTexture(aaDepthTextureDescriptor); + } + } + + // Clear the framebuffer textures after creation + this._clearFramebufferTextures(framebuffer); + } + + _clearFramebufferTextures(framebuffer) { + const commandEncoder = this.device.createCommandEncoder(); + + // Clear the color texture (and multisampled texture if it exists) + const colorTexture = framebuffer.aaColorTexture || framebuffer.colorTexture; + const colorAttachment = { + view: colorTexture.createView(), + loadOp: "clear", + storeOp: "store", + clearValue: { r: 0, g: 0, b: 0, a: 0 }, + resolveTarget: framebuffer.aaColorTexture ? + framebuffer.colorTexture.createView() : undefined, + }; + + // Clear the depth texture if it exists + const depthTexture = framebuffer.aaDepthTexture || framebuffer.depthTexture; + const depthStencilAttachment = depthTexture ? { + view: depthTexture.createView(), + depthLoadOp: "clear", + depthStoreOp: "store", + depthClearValue: 1.0, + stencilLoadOp: "clear", + stencilStoreOp: "store", + depthReadOnly: false, + stencilReadOnly: false, + } : undefined; + + const renderPassDescriptor = { + colorAttachments: [colorAttachment], + depthStencilAttachment: depthStencilAttachment, + }; + + const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor); + passEncoder.end(); + + this.queue.submit([commandEncoder.finish()]); + } + + _getFramebufferColorTextureView(framebuffer) { + if (!framebuffer._colorTextureView && framebuffer.colorTexture) { + framebuffer._colorTextureView = framebuffer.colorTexture.createView(); } + return framebuffer._colorTextureView; + } + + createFramebufferTextureHandle(framebufferTexture) { + const src = framebufferTexture; + let renderer = this; + return { + get view() { + return renderer._getFramebufferColorTextureView(src.framebuffer); + }, + get gpuTexture() { + return src.framebuffer.colorTexture; + } + }; } _getWebGPUColorFormat(framebuffer) { @@ -1263,6 +1414,9 @@ class RendererWebGPU extends Renderer3D { if (framebuffer.depthTexture && framebuffer.depthTexture.destroy) { framebuffer.depthTexture.destroy(); } + if (framebuffer.aaDepthTexture && framebuffer.aaDepthTexture.destroy) { + framebuffer.aaDepthTexture.destroy(); + } } getFramebufferToBind(framebuffer) { @@ -1275,6 +1429,9 @@ class RendererWebGPU extends Renderer3D { bindFramebuffer(framebuffer) {} async readFramebufferPixels(framebuffer) { + // Ensure all pending GPU work is complete before reading pixels + await this.queue.onSubmittedWorkDone(); + const width = framebuffer.width * framebuffer.density; const height = framebuffer.height * framebuffer.density; const bytesPerPixel = 4; @@ -1284,8 +1441,8 @@ class RendererWebGPU extends Renderer3D { const commandEncoder = this.device.createCommandEncoder(); commandEncoder.copyTextureToBuffer( - { texture: framebuffer.colorTexture }, - { buffer: stagingBuffer, bytesPerRow: width * bytesPerPixel }, + { texture: framebuffer.colorTexture, origin: { x: 0, y: 0, z: 0 } }, + { buffer: stagingBuffer, bytesPerRow: width * bytesPerPixel, rowsPerImage: height }, { width, height, depthOrArrayLayers: 1 } ); @@ -1300,6 +1457,9 @@ class RendererWebGPU extends Renderer3D { } async readFramebufferPixel(framebuffer, x, y) { + // Ensure all pending GPU work is complete before reading pixels + await this.queue.onSubmittedWorkDone(); + const bytesPerPixel = 4; const stagingBuffer = this._ensurePixelReadBuffer(bytesPerPixel); @@ -1325,6 +1485,9 @@ class RendererWebGPU extends Renderer3D { } async readFramebufferRegion(framebuffer, x, y, w, h) { + // Ensure all pending GPU work is complete before reading pixels + await this.queue.onSubmittedWorkDone(); + const width = w * framebuffer.density; const height = h * framebuffer.density; const bytesPerPixel = 4; @@ -1391,6 +1554,9 @@ class RendererWebGPU extends Renderer3D { ////////////////////////////////////////////// async loadPixels() { + // Ensure all pending GPU work is complete before reading pixels + await this.queue.onSubmittedWorkDone(); + const width = this.width * this._pixelDensity; const height = this.height * this._pixelDensity; const bytesPerPixel = 4; @@ -1419,6 +1585,9 @@ class RendererWebGPU extends Renderer3D { } async _getPixel(x, y) { + // Ensure all pending GPU work is complete before reading pixels + await this.queue.onSubmittedWorkDone(); + const bytesPerPixel = 4; const stagingBuffer = this._ensurePixelReadBuffer(bytesPerPixel); @@ -1467,6 +1636,9 @@ class RendererWebGPU extends Renderer3D { // get(x,y,w,h) - region } + // Ensure all pending GPU work is complete before reading pixels + await this.queue.onSubmittedWorkDone(); + // Read region and create p5.Image const width = w * pd; const height = h * pd; @@ -1487,17 +1659,19 @@ class RendererWebGPU extends Renderer3D { ); this.device.queue.submit([commandEncoder.finish()]); + await this.queue.onSubmittedWorkDone(); await stagingBuffer.mapAsync(GPUMapMode.READ, 0, bufferSize); const mappedRange = stagingBuffer.getMappedRange(0, bufferSize); const pixelData = new Uint8Array(mappedRange.slice(0, bufferSize)); + console.log(pixelData) const region = new Image(width, height); region.pixelDensity(pd); - region.imageData = region.canvas.getContext('2d').createImageData(width, height); - region.imageData.data.set(pixelData); - region.pixels = region.imageData.data; - region.updatePixels(); + const ctx = region.canvas.getContext('2d'); + const imageData = ctx.createImageData(width, height); + imageData.data.set(pixelData); + ctx.putImageData(imageData, 0, 0); stagingBuffer.unmap(); return region; diff --git a/test/unit/visual/cases/webgpu.js b/test/unit/visual/cases/webgpu.js index 363626807a..9c0502ce39 100644 --- a/test/unit/visual/cases/webgpu.js +++ b/test/unit/visual/cases/webgpu.js @@ -19,7 +19,7 @@ visualSuite('WebGPU', function() { p5.circle(0, 0, 20); p5.pop(); } - screenshot(); + await screenshot(); }); visualTest('The stroke shader runs successfully', async function(p5, screenshot) { @@ -34,7 +34,7 @@ visualSuite('WebGPU', function() { p5.circle(0, 0, 20); p5.pop(); } - screenshot(); + await screenshot(); }); visualTest('The material shader runs successfully', async function(p5, screenshot) { @@ -54,7 +54,7 @@ visualSuite('WebGPU', function() { p5.sphere(10); p5.pop(); } - screenshot(); + await screenshot(); }); visualTest('Shader hooks can be used', async function(p5, screenshot) { @@ -80,7 +80,7 @@ visualSuite('WebGPU', function() { p5.stroke('white'); p5.strokeWeight(5); p5.circle(0, 0, 30); - screenshot(); + await screenshot(); }); visualTest('Textures in the material shader work', async function(p5, screenshot) { @@ -100,17 +100,17 @@ visualSuite('WebGPU', function() { p5.texture(tex); p5.plane(p5.width, p5.height); - screenshot(); + await screenshot(); }); }); visualSuite('Framebuffers', function() { visualTest('Basic framebuffer draw to canvas', async function(p5, screenshot) { await p5.createCanvas(50, 50, p5.WEBGPU); - + // Create a framebuffer const fbo = p5.createFramebuffer({ width: 25, height: 25 }); - + // Draw to the framebuffer fbo.draw(() => { p5.background(255, 0, 0); // Red background @@ -118,23 +118,23 @@ visualSuite('WebGPU', function() { p5.noStroke(); p5.circle(12.5, 12.5, 20); }); - + // Draw the framebuffer to the main canvas p5.background(0, 0, 255); // Blue background p5.texture(fbo); p5.noStroke(); p5.plane(25, 25); - - screenshot(); + + await screenshot(); }); visualTest('Framebuffer with different sizes', async function(p5, screenshot) { await p5.createCanvas(50, 50, p5.WEBGPU); - + // Create two different sized framebuffers const fbo1 = p5.createFramebuffer({ width: 20, height: 20 }); const fbo2 = p5.createFramebuffer({ width: 15, height: 15 }); - + // Draw to first framebuffer fbo1.draw(() => { p5.background(255, 100, 100); @@ -142,15 +142,15 @@ visualSuite('WebGPU', function() { p5.noStroke(); p5.rect(5, 5, 10, 10); }); - - // Draw to second framebuffer + + // Draw to second framebuffer fbo2.draw(() => { p5.background(100, 255, 100); p5.fill(255, 0, 255); p5.noStroke(); p5.circle(7.5, 7.5, 10); }); - + // Draw both to main canvas p5.background(50); p5.push(); @@ -159,23 +159,23 @@ visualSuite('WebGPU', function() { p5.noStroke(); p5.plane(20, 20); p5.pop(); - + p5.push(); p5.translate(12.5, 12.5); p5.texture(fbo2); p5.noStroke(); p5.plane(15, 15); p5.pop(); - - screenshot(); + + await screenshot(); }); visualTest('Auto-sized framebuffer', async function(p5, screenshot) { await p5.createCanvas(50, 50, p5.WEBGPU); - + // Create auto-sized framebuffer (should match canvas size) const fbo = p5.createFramebuffer(); - + // Draw to the framebuffer fbo.draw(() => { p5.background(0); @@ -193,24 +193,24 @@ visualSuite('WebGPU', function() { p5.noStroke(); p5.circle(25, 25, 15); }); - + // Draw the framebuffer to fill the main canvas p5.texture(fbo); p5.noStroke(); p5.plane(50, 50); - - screenshot(); + + await screenshot(); }); visualTest('Auto-sized framebuffer after canvas resize', async function(p5, screenshot) { await p5.createCanvas(50, 50, p5.WEBGPU); - + // Create auto-sized framebuffer const fbo = p5.createFramebuffer(); - + // Resize the canvas (framebuffer should auto-resize) p5.resizeCanvas(30, 30); - + // Draw to the framebuffer after resize fbo.draw(() => { p5.background(100, 0, 100); @@ -221,21 +221,21 @@ visualSuite('WebGPU', function() { p5.fill(255, 255, 0); p5.circle(15, 15, 10); }); - + // Draw the framebuffer to the main canvas p5.texture(fbo); p5.noStroke(); p5.plane(30, 30); - - screenshot(); + + await screenshot(); }); visualTest('Fixed-size framebuffer after manual resize', async function(p5, screenshot) { await p5.createCanvas(50, 50, p5.WEBGPU); - + // Create fixed-size framebuffer const fbo = p5.createFramebuffer({ width: 20, height: 20 }); - + // Draw initial content fbo.draw(() => { p5.background(255, 200, 100); @@ -243,10 +243,10 @@ visualSuite('WebGPU', function() { p5.noStroke(); p5.circle(10, 10, 15); }); - + // Manually resize the framebuffer fbo.resize(35, 25); - + // Draw new content to the resized framebuffer fbo.draw(() => { p5.background(200, 255, 100); @@ -257,14 +257,14 @@ visualSuite('WebGPU', function() { p5.fill(0, 0, 255); p5.circle(17.5, 12.5, 8); }); - + // Draw the resized framebuffer to the main canvas p5.background(50); p5.texture(fbo); p5.noStroke(); p5.plane(35, 25); - - screenshot(); + + await screenshot(); }); }); }); diff --git a/test/unit/visual/visualTest.js b/test/unit/visual/visualTest.js index 120ce79565..7d301d142b 100644 --- a/test/unit/visual/visualTest.js +++ b/test/unit/visual/visualTest.js @@ -89,43 +89,43 @@ export function visualSuite( /** * Image Diff Algorithm for p5.js Visual Tests - * + * * This algorithm addresses the challenge of cross-platform rendering differences in p5.js visual tests. * Different operating systems and browsers render graphics with subtle variations, particularly with * anti-aliasing, text rendering, and sub-pixel positioning. This can cause false negatives in tests * when the visual differences are acceptable rendering variations rather than actual bugs. - * + * * Key components of the approach: - * + * * 1. Initial pixel-by-pixel comparison: * - Uses pixelmatch to identify differences between expected and actual images * - Sets a moderate threshold (0.5) to filter out minor color/intensity variations * - Produces a diff image with red pixels marking differences - * + * * 2. Cluster identification using BFS (Breadth-First Search): * - Groups connected difference pixels into clusters * - Uses a queue-based BFS algorithm to find all connected pixels * - Defines connectivity based on 8-way adjacency (all surrounding pixels) - * + * * 3. Cluster categorization by type: * - Analyzes each pixel's neighborhood characteristics * - Specifically identifies "line shift" clusters - differences that likely represent * the same visual elements shifted by 1px due to platform rendering differences * - Line shifts are identified when >80% of pixels in a cluster have ≤2 neighboring diff pixels - * + * * 4. Intelligent failure criteria: * - Filters out clusters smaller than MIN_CLUSTER_SIZE pixels (noise reduction) * - Applies different thresholds for regular differences vs. line shifts * - Considers both the total number of significant pixels and number of distinct clusters - * - * This approach balances the need to catch genuine visual bugs (like changes to shape geometry, + * + * This approach balances the need to catch genuine visual bugs (like changes to shape geometry, * colors, or positioning) while tolerating acceptable cross-platform rendering variations. - * + * * Parameters: * - MIN_CLUSTER_SIZE: Minimum size for a cluster to be considered significant (default: 4) * - MAX_TOTAL_DIFF_PIXELS: Maximum allowed non-line-shift difference pixels (default: 40) * Note: These can be adjusted for further updation - * + * * Note for contributors: When running tests locally, you may not see these differences as they * mainly appear when tests run on different operating systems or browser rendering engines. * However, the same code may produce slightly different renderings on CI environments, particularly @@ -140,7 +140,7 @@ export async function checkMatch(actual, expected, p5) { if (narrow) { scale *= 2; } - + for (const img of [actual, expected]) { img.resize( Math.ceil(img.width * scale), @@ -151,28 +151,28 @@ export async function checkMatch(actual, expected, p5) { // Ensure both images have the same dimensions const width = expected.width; const height = expected.height; - + // Create canvases with background color const actualCanvas = p5.createGraphics(width, height); const expectedCanvas = p5.createGraphics(width, height); actualCanvas.pixelDensity(1); expectedCanvas.pixelDensity(1); - + actualCanvas.background(BG); expectedCanvas.background(BG); - + actualCanvas.image(actual, 0, 0); expectedCanvas.image(expected, 0, 0); - + // Load pixel data actualCanvas.loadPixels(); expectedCanvas.loadPixels(); - + // Create diff output canvas const diffCanvas = p5.createGraphics(width, height); diffCanvas.pixelDensity(1); diffCanvas.loadPixels(); - + // Run pixelmatch const diffCount = pixelmatch( actualCanvas.pixels, @@ -180,13 +180,13 @@ export async function checkMatch(actual, expected, p5) { diffCanvas.pixels, width, height, - { + { threshold: 0.5, includeAA: false, alpha: 0.1 } ); - + // If no differences, return early if (diffCount === 0) { actualCanvas.remove(); @@ -194,19 +194,19 @@ export async function checkMatch(actual, expected, p5) { diffCanvas.updatePixels(); return { ok: true, diff: diffCanvas }; } - + // Post-process to identify and filter out isolated differences const visited = new Set(); const clusterSizes = []; - + for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const pos = (y * width + x) * 4; - + // If this is a diff pixel (red in pixelmatch output) and not yet visited if ( - diffCanvas.pixels[pos] === 255 && - diffCanvas.pixels[pos + 1] === 0 && + diffCanvas.pixels[pos] === 255 && + diffCanvas.pixels[pos + 1] === 0 && diffCanvas.pixels[pos + 2] === 0 && !visited.has(pos) ) { @@ -216,37 +216,37 @@ export async function checkMatch(actual, expected, p5) { } } } - + // Define significance thresholds const MIN_CLUSTER_SIZE = 4; // Minimum pixels in a significant cluster const MAX_TOTAL_DIFF_PIXELS = 40; // Maximum total different pixels // Determine if the differences are significant const nonLineShiftClusters = clusterSizes.filter(c => !c.isLineShift && c.size >= MIN_CLUSTER_SIZE); - + // Calculate significant differences excluding line shifts const significantDiffPixels = nonLineShiftClusters.reduce((sum, c) => sum + c.size, 0); // Update the diff canvas diffCanvas.updatePixels(); - + // Clean up canvases actualCanvas.remove(); expectedCanvas.remove(); - + // Determine test result const ok = ( - diffCount === 0 || + diffCount === 0 || ( - significantDiffPixels === 0 || + significantDiffPixels === 0 || ( - (significantDiffPixels <= MAX_TOTAL_DIFF_PIXELS) && + (significantDiffPixels <= MAX_TOTAL_DIFF_PIXELS) && (nonLineShiftClusters.length <= 2) // Not too many significant clusters ) ) ); - return { + return { ok, diff: diffCanvas, details: { @@ -264,31 +264,31 @@ function findClusterSize(pixels, startX, startY, width, height, radius, visited) const queue = [{x: startX, y: startY}]; let size = 0; const clusterPixels = []; - + while (queue.length > 0) { const {x, y} = queue.shift(); const pos = (y * width + x) * 4; - + // Skip if already visited if (visited.has(pos)) continue; - + // Skip if not a diff pixel if (pixels[pos] !== 255 || pixels[pos + 1] !== 0 || pixels[pos + 2] !== 0) continue; - + // Mark as visited visited.add(pos); size++; clusterPixels.push({x, y}); - + // Add neighbors to queue for (let dy = -radius; dy <= radius; dy++) { for (let dx = -radius; dx <= radius; dx++) { const nx = x + dx; const ny = y + dy; - + // Skip if out of bounds if (nx < 0 || nx >= width || ny < 0 || ny >= height) continue; - + // Skip if already visited const npos = (ny * width + nx) * 4; if (!visited.has(npos)) { @@ -302,20 +302,20 @@ function findClusterSize(pixels, startX, startY, width, height, radius, visited) if (clusterPixels.length > 0) { // Count pixels with limited neighbors (line-like characteristic) let linelikePixels = 0; - + for (const {x, y} of clusterPixels) { // Count neighbors let neighbors = 0; for (let dy = -1; dy <= 1; dy++) { for (let dx = -1; dx <= 1; dx++) { if (dx === 0 && dy === 0) continue; // Skip self - + const nx = x + dx; const ny = y + dy; - + // Skip if out of bounds if (nx < 0 || nx >= width || ny < 0 || ny >= height) continue; - + const npos = (ny * width + nx) * 4; // Check if neighbor is a diff pixel if (pixels[npos] === 255 && pixels[npos + 1] === 0 && pixels[npos + 2] === 0) { @@ -323,13 +323,13 @@ function findClusterSize(pixels, startX, startY, width, height, radius, visited) } } } - + // Line-like pixels typically have 1-2 neighbors if (neighbors <= 2) { linelikePixels++; } } - + // If most pixels (>80%) in the cluster have ≤2 neighbors, it's likely a line shift isLineShift = linelikePixels / clusterPixels.length > 0.8; } @@ -407,8 +407,8 @@ export function visualTest( const actual = []; // Generate screenshots - await callback(myp5, () => { - const img = myp5.get(); + await callback(myp5, async () => { + const img = await myp5.get(); img.pixelDensity(1); actual.push(img); }); From 4fd6c19b68fee4632793ba5d23604165547f2eb7 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Mon, 28 Jul 2025 18:29:43 -0400 Subject: [PATCH 36/72] Add fixes for other gl-specific cases --- src/webgl/3d_primitives.js | 2 +- src/webgpu/p5.RendererWebGPU.js | 29 +- test/unit/visual/cases/webgpu.js | 424 ++++++++++++++++-------------- test/unit/webgl/p5.Framebuffer.js | 13 +- 4 files changed, 246 insertions(+), 222 deletions(-) diff --git a/src/webgl/3d_primitives.js b/src/webgl/3d_primitives.js index 8c29e3ea2d..386a64535d 100644 --- a/src/webgl/3d_primitives.js +++ b/src/webgl/3d_primitives.js @@ -1869,7 +1869,7 @@ function primitives3D(p5, fn){ if (typeof args[4] === 'undefined') { // Use the retained mode for drawing rectangle, // if args for rounding rectangle is not provided by user. - const perPixelLighting = this._pInst._glAttributes.perPixelLighting; + const perPixelLighting = this._pInst._glAttributes?.perPixelLighting; const detailX = args[4] || (perPixelLighting ? 1 : 24); const detailY = args[5] || (perPixelLighting ? 1 : 16); const gid = `rect|${detailX}|${detailY}`; diff --git a/src/webgpu/p5.RendererWebGPU.js b/src/webgpu/p5.RendererWebGPU.js index 29ffcbf40d..cad16a1765 100644 --- a/src/webgpu/p5.RendererWebGPU.js +++ b/src/webgpu/p5.RendererWebGPU.js @@ -210,7 +210,7 @@ class RendererWebGPU extends Renderer3D { this._getWebGPUColorFormat(activeFramebuffer) : this.presentationFormat; - const requestedSampleCount = activeFramebuffer ? + const requestedSampleCount = activeFramebuffer ? (activeFramebuffer.antialias ? activeFramebuffer.antialiasSamples : 1) : (this.antialias || 1); const sampleCount = this._getValidSampleCount(requestedSampleCount); @@ -564,6 +564,12 @@ class RendererWebGPU extends Renderer3D { this._viewport = [0, 0, this.width, this.height]; } + _createPixelsArray() { + this.pixels = new Uint8Array( + this.width * this.pixelDensity() * this.height * this.pixelDensity() * 4 + ); + } + viewport() {} zClipRange() { @@ -605,25 +611,25 @@ class RendererWebGPU extends Renderer3D { if (!buffers) return; const commandEncoder = this.device.createCommandEncoder(); - + // Use framebuffer texture if active, otherwise use canvas texture const activeFramebuffer = this.activeFramebuffer(); - const colorTexture = activeFramebuffer ? - (activeFramebuffer.aaColorTexture || activeFramebuffer.colorTexture) : + const colorTexture = activeFramebuffer ? + (activeFramebuffer.aaColorTexture || activeFramebuffer.colorTexture) : this.drawingContext.getCurrentTexture(); - + const colorAttachment = { view: colorTexture.createView(), loadOp: "load", storeOp: "store", // If using multisampled texture, resolve to non-multisampled texture - resolveTarget: activeFramebuffer && activeFramebuffer.aaColorTexture ? + resolveTarget: activeFramebuffer && activeFramebuffer.aaColorTexture ? activeFramebuffer.colorTexture.createView() : undefined, }; // Use framebuffer depth texture if active, otherwise use canvas depth texture - const depthTexture = activeFramebuffer ? - (activeFramebuffer.aaDepthTexture || activeFramebuffer.depthTexture) : + const depthTexture = activeFramebuffer ? + (activeFramebuffer.aaDepthTexture || activeFramebuffer.depthTexture) : this.depthTexture; const depthTextureView = depthTexture?.createView(); const renderPassDescriptor = { @@ -1313,14 +1319,14 @@ class RendererWebGPU extends Renderer3D { framebuffer.aaDepthTexture = this.device.createTexture(aaDepthTextureDescriptor); } } - + // Clear the framebuffer textures after creation this._clearFramebufferTextures(framebuffer); } _clearFramebufferTextures(framebuffer) { const commandEncoder = this.device.createCommandEncoder(); - + // Clear the color texture (and multisampled texture if it exists) const colorTexture = framebuffer.aaColorTexture || framebuffer.colorTexture; const colorAttachment = { @@ -1328,7 +1334,7 @@ class RendererWebGPU extends Renderer3D { loadOp: "clear", storeOp: "store", clearValue: { r: 0, g: 0, b: 0, a: 0 }, - resolveTarget: framebuffer.aaColorTexture ? + resolveTarget: framebuffer.aaColorTexture ? framebuffer.colorTexture.createView() : undefined, }; @@ -1664,7 +1670,6 @@ class RendererWebGPU extends Renderer3D { await stagingBuffer.mapAsync(GPUMapMode.READ, 0, bufferSize); const mappedRange = stagingBuffer.getMappedRange(0, bufferSize); const pixelData = new Uint8Array(mappedRange.slice(0, bufferSize)); - console.log(pixelData) const region = new Image(width, height); region.pixelDensity(pd); diff --git a/test/unit/visual/cases/webgpu.js b/test/unit/visual/cases/webgpu.js index 9c0502ce39..334abc1be1 100644 --- a/test/unit/visual/cases/webgpu.js +++ b/test/unit/visual/cases/webgpu.js @@ -1,176 +1,194 @@ -import { vi } from 'vitest'; -import p5 from '../../../../src/app'; -import { visualSuite, visualTest } from '../visualTest'; -import rendererWebGPU from '../../../../src/webgpu/p5.RendererWebGPU'; +import { vi } from "vitest"; +import p5 from "../../../../src/app"; +import { visualSuite, visualTest } from "../visualTest"; +import rendererWebGPU from "../../../../src/webgpu/p5.RendererWebGPU"; p5.registerAddon(rendererWebGPU); -visualSuite('WebGPU', function() { - visualSuite('Shaders', function() { - visualTest('The color shader runs successfully', async function(p5, screenshot) { - await p5.createCanvas(50, 50, p5.WEBGPU); - p5.background('white'); - for (const [i, color] of ['red', 'lime', 'blue'].entries()) { - p5.push(); - p5.rotate(p5.TWO_PI * (i / 3)); - p5.fill(color); - p5.translate(15, 0); - p5.noStroke(); - p5.circle(0, 0, 20); - p5.pop(); - } - await screenshot(); - }); - - visualTest('The stroke shader runs successfully', async function(p5, screenshot) { - await p5.createCanvas(50, 50, p5.WEBGPU); - p5.background('white'); - for (const [i, color] of ['red', 'lime', 'blue'].entries()) { - p5.push(); - p5.rotate(p5.TWO_PI * (i / 3)); - p5.translate(15, 0); - p5.stroke(color); - p5.strokeWeight(2); - p5.circle(0, 0, 20); - p5.pop(); - } - await screenshot(); - }); - - visualTest('The material shader runs successfully', async function(p5, screenshot) { - await p5.createCanvas(50, 50, p5.WEBGPU); - p5.background('white'); - p5.ambientLight(50); - p5.directionalLight(100, 100, 100, 0, 1, -1); - p5.pointLight(155, 155, 155, 0, -200, 500); - p5.specularMaterial(255); - p5.shininess(300); - for (const [i, color] of ['red', 'lime', 'blue'].entries()) { - p5.push(); - p5.rotate(p5.TWO_PI * (i / 3)); - p5.fill(color); - p5.translate(15, 0); - p5.noStroke(); - p5.sphere(10); - p5.pop(); - } - await screenshot(); - }); +visualSuite("WebGPU", function () { + visualSuite("Shaders", function () { + visualTest( + "The color shader runs successfully", + async function (p5, screenshot) { + await p5.createCanvas(50, 50, p5.WEBGPU); + p5.background("white"); + for (const [i, color] of ["red", "lime", "blue"].entries()) { + p5.push(); + p5.rotate(p5.TWO_PI * (i / 3)); + p5.fill(color); + p5.translate(15, 0); + p5.noStroke(); + p5.circle(0, 0, 20); + p5.pop(); + } + await screenshot(); + }, + ); + + visualTest( + "The stroke shader runs successfully", + async function (p5, screenshot) { + await p5.createCanvas(50, 50, p5.WEBGPU); + p5.background("white"); + for (const [i, color] of ["red", "lime", "blue"].entries()) { + p5.push(); + p5.rotate(p5.TWO_PI * (i / 3)); + p5.translate(15, 0); + p5.stroke(color); + p5.strokeWeight(2); + p5.circle(0, 0, 20); + p5.pop(); + } + await screenshot(); + }, + ); + + visualTest( + "The material shader runs successfully", + async function (p5, screenshot) { + await p5.createCanvas(50, 50, p5.WEBGPU); + p5.background("white"); + p5.ambientLight(50); + p5.directionalLight(100, 100, 100, 0, 1, -1); + p5.pointLight(155, 155, 155, 0, -200, 500); + p5.specularMaterial(255); + p5.shininess(300); + for (const [i, color] of ["red", "lime", "blue"].entries()) { + p5.push(); + p5.rotate(p5.TWO_PI * (i / 3)); + p5.fill(color); + p5.translate(15, 0); + p5.noStroke(); + p5.sphere(10); + p5.pop(); + } + await screenshot(); + }, + ); - visualTest('Shader hooks can be used', async function(p5, screenshot) { + visualTest("Shader hooks can be used", async function (p5, screenshot) { await p5.createCanvas(50, 50, p5.WEBGPU); const myFill = p5.baseMaterialShader().modify({ - 'Vertex getWorldInputs': `(inputs: Vertex) { + "Vertex getWorldInputs": `(inputs: Vertex) { var result = inputs; result.position.y += 10.0 * sin(inputs.position.x * 0.25); return result; }`, }); const myStroke = p5.baseStrokeShader().modify({ - 'StrokeVertex getWorldInputs': `(inputs: StrokeVertex) { + "StrokeVertex getWorldInputs": `(inputs: StrokeVertex) { var result = inputs; result.position.y += 10.0 * sin(inputs.position.x * 0.25); return result; }`, }); - p5.background('black'); + p5.background("black"); p5.shader(myFill); p5.strokeShader(myStroke); - p5.fill('red'); - p5.stroke('white'); + p5.fill("red"); + p5.stroke("white"); p5.strokeWeight(5); p5.circle(0, 0, 30); await screenshot(); }); - visualTest('Textures in the material shader work', async function(p5, screenshot) { - await p5.createCanvas(50, 50, p5.WEBGPU); - const tex = p5.createImage(50, 50); - tex.loadPixels(); - for (let x = 0; x < tex.width; x++) { - for (let y = 0; y < tex.height; y++) { - const off = (x + y * tex.width) * 4; - tex.pixels[off] = p5.round((x / tex.width) * 255); - tex.pixels[off + 1] = p5.round((y / tex.height) * 255); - tex.pixels[off + 2] = 0; - tex.pixels[off + 3] = 255; + visualTest( + "Textures in the material shader work", + async function (p5, screenshot) { + await p5.createCanvas(50, 50, p5.WEBGPU); + const tex = p5.createImage(50, 50); + tex.loadPixels(); + for (let x = 0; x < tex.width; x++) { + for (let y = 0; y < tex.height; y++) { + const off = (x + y * tex.width) * 4; + tex.pixels[off] = p5.round((x / tex.width) * 255); + tex.pixels[off + 1] = p5.round((y / tex.height) * 255); + tex.pixels[off + 2] = 0; + tex.pixels[off + 3] = 255; + } } - } - tex.updatePixels(); - p5.texture(tex); - p5.plane(p5.width, p5.height); + tex.updatePixels(); + p5.texture(tex); + p5.plane(p5.width, p5.height); - await screenshot(); - }); + await screenshot(); + }, + ); }); - visualSuite('Framebuffers', function() { - visualTest('Basic framebuffer draw to canvas', async function(p5, screenshot) { - await p5.createCanvas(50, 50, p5.WEBGPU); - - // Create a framebuffer - const fbo = p5.createFramebuffer({ width: 25, height: 25 }); - - // Draw to the framebuffer - fbo.draw(() => { - p5.background(255, 0, 0); // Red background - p5.fill(0, 255, 0); // Green circle + visualSuite("Framebuffers", function () { + visualTest( + "Basic framebuffer draw to canvas", + async function (p5, screenshot) { + await p5.createCanvas(50, 50, p5.WEBGPU); + + // Create a framebuffer + const fbo = p5.createFramebuffer({ width: 25, height: 25 }); + + // Draw to the framebuffer + fbo.draw(() => { + p5.background(255, 0, 0); // Red background + p5.fill(0, 255, 0); // Green circle + p5.noStroke(); + p5.circle(12.5, 12.5, 20); + }); + + // Draw the framebuffer to the main canvas + p5.background(0, 0, 255); // Blue background + p5.texture(fbo); p5.noStroke(); - p5.circle(12.5, 12.5, 20); - }); - - // Draw the framebuffer to the main canvas - p5.background(0, 0, 255); // Blue background - p5.texture(fbo); - p5.noStroke(); - p5.plane(25, 25); - - await screenshot(); - }); - - visualTest('Framebuffer with different sizes', async function(p5, screenshot) { - await p5.createCanvas(50, 50, p5.WEBGPU); - - // Create two different sized framebuffers - const fbo1 = p5.createFramebuffer({ width: 20, height: 20 }); - const fbo2 = p5.createFramebuffer({ width: 15, height: 15 }); - - // Draw to first framebuffer - fbo1.draw(() => { - p5.background(255, 100, 100); - p5.fill(255, 255, 0); + p5.plane(25, 25); + + await screenshot(); + }, + ); + + visualTest( + "Framebuffer with different sizes", + async function (p5, screenshot) { + await p5.createCanvas(50, 50, p5.WEBGPU); + + // Create two different sized framebuffers + const fbo1 = p5.createFramebuffer({ width: 20, height: 20 }); + const fbo2 = p5.createFramebuffer({ width: 15, height: 15 }); + + // Draw to first framebuffer + fbo1.draw(() => { + p5.background(255, 100, 100); + p5.fill(255, 255, 0); + p5.noStroke(); + p5.rect(5, 5, 10, 10); + }); + + // Draw to second framebuffer + fbo2.draw(() => { + p5.background(100, 255, 100); + p5.fill(255, 0, 255); + p5.noStroke(); + p5.circle(7.5, 7.5, 10); + }); + + // Draw both to main canvas + p5.background(50); + p5.push(); + p5.translate(-12.5, -12.5); + p5.texture(fbo1); p5.noStroke(); - p5.rect(5, 5, 10, 10); - }); + p5.plane(20, 20); + p5.pop(); - // Draw to second framebuffer - fbo2.draw(() => { - p5.background(100, 255, 100); - p5.fill(255, 0, 255); + p5.push(); + p5.translate(12.5, 12.5); + p5.texture(fbo2); p5.noStroke(); - p5.circle(7.5, 7.5, 10); - }); - - // Draw both to main canvas - p5.background(50); - p5.push(); - p5.translate(-12.5, -12.5); - p5.texture(fbo1); - p5.noStroke(); - p5.plane(20, 20); - p5.pop(); + p5.plane(15, 15); + p5.pop(); - p5.push(); - p5.translate(12.5, 12.5); - p5.texture(fbo2); - p5.noStroke(); - p5.plane(15, 15); - p5.pop(); + await screenshot(); + }, + ); - await screenshot(); - }); - - visualTest('Auto-sized framebuffer', async function(p5, screenshot) { + visualTest("Auto-sized framebuffer", async function (p5, screenshot) { await p5.createCanvas(50, 50, p5.WEBGPU); // Create auto-sized framebuffer (should match canvas size) @@ -202,69 +220,75 @@ visualSuite('WebGPU', function() { await screenshot(); }); - visualTest('Auto-sized framebuffer after canvas resize', async function(p5, screenshot) { - await p5.createCanvas(50, 50, p5.WEBGPU); - - // Create auto-sized framebuffer - const fbo = p5.createFramebuffer(); - - // Resize the canvas (framebuffer should auto-resize) - p5.resizeCanvas(30, 30); - - // Draw to the framebuffer after resize - fbo.draw(() => { - p5.background(100, 0, 100); - p5.fill(0, 255, 255); + visualTest( + "Auto-sized framebuffer after canvas resize", + async function (p5, screenshot) { + await p5.createCanvas(50, 50, p5.WEBGPU); + + // Create auto-sized framebuffer + const fbo = p5.createFramebuffer(); + + // Resize the canvas (framebuffer should auto-resize) + p5.resizeCanvas(30, 30); + + // Draw to the framebuffer after resize + fbo.draw(() => { + p5.background(100, 0, 100); + p5.fill(0, 255, 255); + p5.noStroke(); + // Draw a shape that fills the new size + p5.rect(5, 5, 20, 20); + p5.fill(255, 255, 0); + p5.circle(15, 15, 10); + }); + + // Draw the framebuffer to the main canvas + p5.texture(fbo); p5.noStroke(); - // Draw a shape that fills the new size - p5.rect(5, 5, 20, 20); - p5.fill(255, 255, 0); - p5.circle(15, 15, 10); - }); - - // Draw the framebuffer to the main canvas - p5.texture(fbo); - p5.noStroke(); - p5.plane(30, 30); - - await screenshot(); - }); - - visualTest('Fixed-size framebuffer after manual resize', async function(p5, screenshot) { - await p5.createCanvas(50, 50, p5.WEBGPU); - - // Create fixed-size framebuffer - const fbo = p5.createFramebuffer({ width: 20, height: 20 }); - - // Draw initial content - fbo.draw(() => { - p5.background(255, 200, 100); - p5.fill(0, 100, 200); - p5.noStroke(); - p5.circle(10, 10, 15); - }); - - // Manually resize the framebuffer - fbo.resize(35, 25); - - // Draw new content to the resized framebuffer - fbo.draw(() => { - p5.background(200, 255, 100); - p5.fill(200, 0, 100); + p5.plane(30, 30); + + await screenshot(); + }, + ); + + visualTest( + "Fixed-size framebuffer after manual resize", + async function (p5, screenshot) { + await p5.createCanvas(50, 50, p5.WEBGPU); + + // Create fixed-size framebuffer + const fbo = p5.createFramebuffer({ width: 20, height: 20 }); + + // Draw initial content + fbo.draw(() => { + p5.background(255, 200, 100); + p5.fill(0, 100, 200); + p5.noStroke(); + p5.circle(10, 10, 15); + }); + + // Manually resize the framebuffer + fbo.resize(35, 25); + + // Draw new content to the resized framebuffer + fbo.draw(() => { + p5.background(200, 255, 100); + p5.fill(200, 0, 100); + p5.noStroke(); + // Draw content that uses the new size + p5.rect(5, 5, 25, 15); + p5.fill(0, 0, 255); + p5.circle(17.5, 12.5, 8); + }); + + // Draw the resized framebuffer to the main canvas + p5.background(50); + p5.texture(fbo); p5.noStroke(); - // Draw content that uses the new size - p5.rect(5, 5, 25, 15); - p5.fill(0, 0, 255); - p5.circle(17.5, 12.5, 8); - }); + p5.plane(35, 25); - // Draw the resized framebuffer to the main canvas - p5.background(50); - p5.texture(fbo); - p5.noStroke(); - p5.plane(35, 25); - - await screenshot(); - }); + await screenshot(); + }, + ); }); }); diff --git a/test/unit/webgl/p5.Framebuffer.js b/test/unit/webgl/p5.Framebuffer.js index f97cb6b57d..6a6d556351 100644 --- a/test/unit/webgl/p5.Framebuffer.js +++ b/test/unit/webgl/p5.Framebuffer.js @@ -461,7 +461,7 @@ suite('p5.Framebuffer', function() { } }); - test('get() creates a p5.Image with 1x pixel density', function() { + test('get() creates a p5.Image matching the source pixel density', function() { const mainCanvas = myp5.createCanvas(20, 20, myp5.WEBGL); myp5.pixelDensity(2); const fbo = myp5.createFramebuffer(); @@ -482,22 +482,17 @@ suite('p5.Framebuffer', function() { myp5.pop(); }); const img = fbo.get(); - const p2d = myp5.createGraphics(20, 20); - p2d.pixelDensity(1); myp5.image(fbo, -10, -10); - p2d.image(mainCanvas, 0, 0); fbo.loadPixels(); img.loadPixels(); - p2d.loadPixels(); expect(img.width).to.equal(fbo.width); expect(img.height).to.equal(fbo.height); - expect(img.pixels.length).to.equal(fbo.pixels.length / 4); - // The pixels should be approximately the same in the 1x image as when we - // draw the framebuffer onto a 1x canvas + expect(img.pixels.length).to.equal(fbo.pixels.length); + // The pixels should be approximately the same as the framebuffer's for (let i = 0; i < img.pixels.length; i++) { - expect(img.pixels[i]).to.be.closeTo(p2d.pixels[i], 2); + expect(img.pixels[i]).to.be.closeTo(fbo.pixels[i], 2); } }); }); From edce0299a0a6e89cbc867ad2b9d47f59df4aad57 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Mon, 28 Jul 2025 21:00:19 -0400 Subject: [PATCH 37/72] Fix canvas readback --- src/core/main.js | 4 +- src/webgpu/p5.RendererWebGPU.js | 253 ++++++++++++++++++++++++----- test/unit/webgpu/p5.Framebuffer.js | 35 ++-- 3 files changed, 235 insertions(+), 57 deletions(-) diff --git a/src/core/main.js b/src/core/main.js index a5c9a6c93d..f9fc1c6559 100644 --- a/src/core/main.js +++ b/src/core/main.js @@ -468,11 +468,11 @@ for (const k in constants) { * If `setup()` is declared `async` (e.g. `async function setup()`), * execution pauses at each `await` until its promise resolves. * For example, `font = await loadFont(...)` waits for the font asset - * to load because `loadFont()` function returns a promise, and the await + * to load because `loadFont()` function returns a promise, and the await * keyword means the program will wait for the promise to resolve. * This ensures that all assets are fully loaded before the sketch continues. - * + * * loading assets. * * Note: `setup()` doesn’t have to be declared, but it’s common practice to do so. diff --git a/src/webgpu/p5.RendererWebGPU.js b/src/webgpu/p5.RendererWebGPU.js index cad16a1765..383cf9f97f 100644 --- a/src/webgpu/p5.RendererWebGPU.js +++ b/src/webgpu/p5.RendererWebGPU.js @@ -23,6 +23,9 @@ class RendererWebGPU extends Renderer3D { // Single reusable staging buffer for pixel reading this.pixelReadBuffer = null; this.pixelReadBufferSize = 0; + + // Lazy readback texture for main canvas pixel reading + this.canvasReadbackTexture = null; } async setupContext() { @@ -62,6 +65,12 @@ class RendererWebGPU extends Renderer3D { format: this.depthFormat, usage: GPUTextureUsage.RENDER_ATTACHMENT, }); + + // Destroy existing readback texture when size changes + if (this.canvasReadbackTexture && this.canvasReadbackTexture.destroy) { + this.canvasReadbackTexture.destroy(); + this.canvasReadbackTexture = null; + } } clear(...args) { @@ -71,16 +80,28 @@ class RendererWebGPU extends Renderer3D { const _a = args[3] || 0; const commandEncoder = this.device.createCommandEncoder(); - const textureView = this.drawingContext.getCurrentTexture().createView(); + + // Use framebuffer texture if active, otherwise use canvas texture + const activeFramebuffer = this.activeFramebuffer(); + const colorTexture = activeFramebuffer ? + (activeFramebuffer.aaColorTexture || activeFramebuffer.colorTexture) : + this.drawingContext.getCurrentTexture(); const colorAttachment = { - view: textureView, + view: colorTexture.createView(), clearValue: { r: _r * _a, g: _g * _a, b: _b * _a, a: _a }, loadOp: 'clear', storeOp: 'store', + // If using multisampled texture, resolve to non-multisampled texture + resolveTarget: activeFramebuffer && activeFramebuffer.aaColorTexture ? + activeFramebuffer.colorTexture.createView() : undefined, }; - const depthTextureView = this.depthTexture?.createView(); + // Use framebuffer depth texture if active, otherwise use canvas depth texture + const depthTexture = activeFramebuffer ? + (activeFramebuffer.aaDepthTexture || activeFramebuffer.depthTexture) : + this.depthTexture; + const depthTextureView = depthTexture?.createView(); const depthAttachment = depthTextureView ? { view: depthTextureView, @@ -1202,6 +1223,11 @@ class RendererWebGPU extends Renderer3D { return this.pixelReadBuffer; } + _alignBytesPerRow(bytesPerRow) { + // WebGPU requires bytesPerRow to be a multiple of 256 bytes for texture-to-buffer copies + return Math.ceil(bytesPerRow / 256) * 256; + } + ////////////////////////////////////////////// // Framebuffer methods ////////////////////////////////////////////// @@ -1435,31 +1461,56 @@ class RendererWebGPU extends Renderer3D { bindFramebuffer(framebuffer) {} async readFramebufferPixels(framebuffer) { - // Ensure all pending GPU work is complete before reading pixels - await this.queue.onSubmittedWorkDone(); - const width = framebuffer.width * framebuffer.density; const height = framebuffer.height * framebuffer.density; const bytesPerPixel = 4; - const bufferSize = width * height * bytesPerPixel; - - const stagingBuffer = this._ensurePixelReadBuffer(bufferSize); + const unalignedBytesPerRow = width * bytesPerPixel; + const alignedBytesPerRow = this._alignBytesPerRow(unalignedBytesPerRow); + const bufferSize = alignedBytesPerRow * height; + + // const stagingBuffer = this._ensurePixelReadBuffer(bufferSize); + const stagingBuffer = this.device.createBuffer({ + size: bufferSize, + usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ, + }); const commandEncoder = this.device.createCommandEncoder(); commandEncoder.copyTextureToBuffer( - { texture: framebuffer.colorTexture, origin: { x: 0, y: 0, z: 0 } }, - { buffer: stagingBuffer, bytesPerRow: width * bytesPerPixel, rowsPerImage: height }, + { + texture: framebuffer.colorTexture, + origin: { x: 0, y: 0, z: 0 }, + mipLevel: 0, + aspect: 'all' + }, + { buffer: stagingBuffer, bytesPerRow: alignedBytesPerRow, rowsPerImage: height }, { width, height, depthOrArrayLayers: 1 } ); this.device.queue.submit([commandEncoder.finish()]); + // Wait for the copy operation to complete + // await this.queue.onSubmittedWorkDone(); + await stagingBuffer.mapAsync(GPUMapMode.READ, 0, bufferSize); const mappedRange = stagingBuffer.getMappedRange(0, bufferSize); - const result = new Uint8Array(mappedRange.slice(0, bufferSize)); - stagingBuffer.unmap(); - return result; + // If alignment was needed, extract the actual pixel data + if (alignedBytesPerRow === unalignedBytesPerRow) { + const result = new Uint8Array(mappedRange.slice(0, width * height * bytesPerPixel)); + stagingBuffer.unmap(); + return result; + } else { + // Need to extract pixel data from aligned buffer + const result = new Uint8Array(width * height * bytesPerPixel); + const mappedData = new Uint8Array(mappedRange); + for (let y = 0; y < height; y++) { + const srcOffset = y * alignedBytesPerRow; + const dstOffset = y * unalignedBytesPerRow; + result.set(mappedData.subarray(srcOffset, srcOffset + unalignedBytesPerRow), dstOffset); + } + stagingBuffer.unmap(); + return result; + } } async readFramebufferPixel(framebuffer, x, y) { @@ -1467,7 +1518,10 @@ class RendererWebGPU extends Renderer3D { await this.queue.onSubmittedWorkDone(); const bytesPerPixel = 4; - const stagingBuffer = this._ensurePixelReadBuffer(bytesPerPixel); + const alignedBytesPerRow = this._alignBytesPerRow(bytesPerPixel); + const bufferSize = alignedBytesPerRow; + + const stagingBuffer = this._ensurePixelReadBuffer(bufferSize); const commandEncoder = this.device.createCommandEncoder(); commandEncoder.copyTextureToBuffer( @@ -1475,14 +1529,14 @@ class RendererWebGPU extends Renderer3D { texture: framebuffer.colorTexture, origin: { x, y, z: 0 } }, - { buffer: stagingBuffer, bytesPerRow: bytesPerPixel }, + { buffer: stagingBuffer, bytesPerRow: alignedBytesPerRow }, { width: 1, height: 1, depthOrArrayLayers: 1 } ); this.device.queue.submit([commandEncoder.finish()]); - await stagingBuffer.mapAsync(GPUMapMode.READ, 0, bytesPerPixel); - const mappedRange = stagingBuffer.getMappedRange(0, bytesPerPixel); + await stagingBuffer.mapAsync(GPUMapMode.READ, 0, bufferSize); + const mappedRange = stagingBuffer.getMappedRange(0, bufferSize); const pixelData = new Uint8Array(mappedRange); const result = [pixelData[0], pixelData[1], pixelData[2], pixelData[3]]; @@ -1497,7 +1551,9 @@ class RendererWebGPU extends Renderer3D { const width = w * framebuffer.density; const height = h * framebuffer.density; const bytesPerPixel = 4; - const bufferSize = width * height * bytesPerPixel; + const unalignedBytesPerRow = width * bytesPerPixel; + const alignedBytesPerRow = this._alignBytesPerRow(unalignedBytesPerRow); + const bufferSize = alignedBytesPerRow * height; const stagingBuffer = this._ensurePixelReadBuffer(bufferSize); @@ -1505,9 +1561,10 @@ class RendererWebGPU extends Renderer3D { commandEncoder.copyTextureToBuffer( { texture: framebuffer.colorTexture, + mipLevel: 0, origin: { x: x * framebuffer.density, y: y * framebuffer.density, z: 0 } }, - { buffer: stagingBuffer, bytesPerRow: width * bytesPerPixel }, + { buffer: stagingBuffer, bytesPerRow: alignedBytesPerRow }, { width, height, depthOrArrayLayers: 1 } ); @@ -1515,7 +1572,20 @@ class RendererWebGPU extends Renderer3D { await stagingBuffer.mapAsync(GPUMapMode.READ, 0, bufferSize); const mappedRange = stagingBuffer.getMappedRange(0, bufferSize); - const pixelData = new Uint8Array(mappedRange.slice(0, bufferSize)); + + let pixelData; + if (alignedBytesPerRow === unalignedBytesPerRow) { + pixelData = new Uint8Array(mappedRange.slice(0, width * height * bytesPerPixel)); + } else { + // Need to extract pixel data from aligned buffer + pixelData = new Uint8Array(width * height * bytesPerPixel); + const mappedData = new Uint8Array(mappedRange); + for (let y = 0; y < height; y++) { + const srcOffset = y * alignedBytesPerRow; + const dstOffset = y * unalignedBytesPerRow; + pixelData.set(mappedData.subarray(srcOffset, srcOffset + unalignedBytesPerRow), dstOffset); + } + } // WebGPU doesn't need vertical flipping unlike WebGL const region = new Image(width, height); @@ -1559,24 +1629,75 @@ class RendererWebGPU extends Renderer3D { // Main canvas pixel methods ////////////////////////////////////////////// - async loadPixels() { - // Ensure all pending GPU work is complete before reading pixels - await this.queue.onSubmittedWorkDone(); + _ensureCanvasReadbackTexture() { + if (!this.canvasReadbackTexture) { + const width = Math.ceil(this.width * this._pixelDensity); + const height = Math.ceil(this.height * this._pixelDensity); + + this.canvasReadbackTexture = this.device.createTexture({ + size: { width, height, depthOrArrayLayers: 1 }, + format: this.presentationFormat, + usage: GPUTextureUsage.COPY_DST | GPUTextureUsage.COPY_SRC, + }); + } + return this.canvasReadbackTexture; + } + + _copyCanvasToReadbackTexture() { + // Get the current canvas texture BEFORE any awaiting + const canvasTexture = this.drawingContext.getCurrentTexture(); + + // Ensure readback texture exists + const readbackTexture = this._ensureCanvasReadbackTexture(); + + // Copy canvas texture to readback texture immediately + const copyEncoder = this.device.createCommandEncoder(); + copyEncoder.copyTextureToTexture( + { texture: canvasTexture }, + { texture: readbackTexture }, + { + width: Math.ceil(this.width * this._pixelDensity), + height: Math.ceil(this.height * this._pixelDensity), + depthOrArrayLayers: 1 + } + ); + this.device.queue.submit([copyEncoder.finish()]); + + return readbackTexture; + } + + _convertBGRtoRGB(pixelData) { + // Convert BGR to RGB by swapping red and blue channels + for (let i = 0; i < pixelData.length; i += 4) { + const temp = pixelData[i]; // Store red + pixelData[i] = pixelData[i + 2]; // Red = Blue + pixelData[i + 2] = temp; // Blue = Red + // Green (i + 1) and Alpha (i + 3) stay the same + } + return pixelData; + } + async loadPixels() { const width = this.width * this._pixelDensity; const height = this.height * this._pixelDensity; + + // Copy canvas to readback texture + const readbackTexture = this._copyCanvasToReadbackTexture(); + + // Now we can safely await + await this.queue.onSubmittedWorkDone(); + const bytesPerPixel = 4; - const bufferSize = width * height * bytesPerPixel; + const unalignedBytesPerRow = width * bytesPerPixel; + const alignedBytesPerRow = this._alignBytesPerRow(unalignedBytesPerRow); + const bufferSize = alignedBytesPerRow * height; const stagingBuffer = this._ensurePixelReadBuffer(bufferSize); - // Get the current canvas texture - const canvasTexture = this.drawingContext.getCurrentTexture(); - const commandEncoder = this.device.createCommandEncoder(); commandEncoder.copyTextureToBuffer( - { texture: canvasTexture }, - { buffer: stagingBuffer, bytesPerRow: width * bytesPerPixel }, + { texture: readbackTexture }, + { buffer: stagingBuffer, bytesPerRow: alignedBytesPerRow }, { width, height, depthOrArrayLayers: 1 } ); @@ -1584,36 +1705,58 @@ class RendererWebGPU extends Renderer3D { await stagingBuffer.mapAsync(GPUMapMode.READ, 0, bufferSize); const mappedRange = stagingBuffer.getMappedRange(0, bufferSize); - this.pixels = new Uint8Array(mappedRange.slice(0, bufferSize)); + + if (alignedBytesPerRow === unalignedBytesPerRow) { + this.pixels = new Uint8Array(mappedRange.slice(0, width * height * bytesPerPixel)); + } else { + // Need to extract pixel data from aligned buffer + this.pixels = new Uint8Array(width * height * bytesPerPixel); + const mappedData = new Uint8Array(mappedRange); + for (let y = 0; y < height; y++) { + const srcOffset = y * alignedBytesPerRow; + const dstOffset = y * unalignedBytesPerRow; + this.pixels.set(mappedData.subarray(srcOffset, srcOffset + unalignedBytesPerRow), dstOffset); + } + } + + // Convert BGR to RGB for main canvas + this._convertBGRtoRGB(this.pixels); stagingBuffer.unmap(); return this.pixels; } async _getPixel(x, y) { - // Ensure all pending GPU work is complete before reading pixels + // Copy canvas to readback texture + const readbackTexture = this._copyCanvasToReadbackTexture(); + + // Now we can safely await await this.queue.onSubmittedWorkDone(); const bytesPerPixel = 4; - const stagingBuffer = this._ensurePixelReadBuffer(bytesPerPixel); + const alignedBytesPerRow = this._alignBytesPerRow(bytesPerPixel); + const bufferSize = alignedBytesPerRow; + + const stagingBuffer = this._ensurePixelReadBuffer(bufferSize); - const canvasTexture = this.drawingContext.getCurrentTexture(); const commandEncoder = this.device.createCommandEncoder(); commandEncoder.copyTextureToBuffer( { - texture: canvasTexture, + texture: readbackTexture, origin: { x, y, z: 0 } }, - { buffer: stagingBuffer, bytesPerRow: bytesPerPixel }, + { buffer: stagingBuffer, bytesPerRow: alignedBytesPerRow }, { width: 1, height: 1, depthOrArrayLayers: 1 } ); this.device.queue.submit([commandEncoder.finish()]); - await stagingBuffer.mapAsync(GPUMapMode.READ, 0, bytesPerPixel); - const mappedRange = stagingBuffer.getMappedRange(0, bytesPerPixel); + await stagingBuffer.mapAsync(GPUMapMode.READ, 0, bufferSize); + const mappedRange = stagingBuffer.getMappedRange(0, bufferSize); const pixelData = new Uint8Array(mappedRange); - const result = [pixelData[0], pixelData[1], pixelData[2], pixelData[3]]; + + // Convert BGR to RGB for main canvas - swap red and blue + const result = [pixelData[2], pixelData[1], pixelData[0], pixelData[3]]; stagingBuffer.unmap(); return result; @@ -1642,25 +1785,29 @@ class RendererWebGPU extends Renderer3D { // get(x,y,w,h) - region } - // Ensure all pending GPU work is complete before reading pixels + // Copy canvas to readback texture + const readbackTexture = this._copyCanvasToReadbackTexture(); + + // Now we can safely await await this.queue.onSubmittedWorkDone(); // Read region and create p5.Image const width = w * pd; const height = h * pd; const bytesPerPixel = 4; - const bufferSize = width * height * bytesPerPixel; + const unalignedBytesPerRow = width * bytesPerPixel; + const alignedBytesPerRow = this._alignBytesPerRow(unalignedBytesPerRow); + const bufferSize = alignedBytesPerRow * height; const stagingBuffer = this._ensurePixelReadBuffer(bufferSize); - const canvasTexture = this.drawingContext.getCurrentTexture(); const commandEncoder = this.device.createCommandEncoder(); commandEncoder.copyTextureToBuffer( { - texture: canvasTexture, + texture: readbackTexture, origin: { x, y, z: 0 } }, - { buffer: stagingBuffer, bytesPerRow: width * bytesPerPixel }, + { buffer: stagingBuffer, bytesPerRow: alignedBytesPerRow }, { width, height, depthOrArrayLayers: 1 } ); @@ -1669,7 +1816,23 @@ class RendererWebGPU extends Renderer3D { await stagingBuffer.mapAsync(GPUMapMode.READ, 0, bufferSize); const mappedRange = stagingBuffer.getMappedRange(0, bufferSize); - const pixelData = new Uint8Array(mappedRange.slice(0, bufferSize)); + + let pixelData; + if (alignedBytesPerRow === unalignedBytesPerRow) { + pixelData = new Uint8Array(mappedRange.slice(0, width * height * bytesPerPixel)); + } else { + // Need to extract pixel data from aligned buffer + pixelData = new Uint8Array(width * height * bytesPerPixel); + const mappedData = new Uint8Array(mappedRange); + for (let y = 0; y < height; y++) { + const srcOffset = y * alignedBytesPerRow; + const dstOffset = y * unalignedBytesPerRow; + pixelData.set(mappedData.subarray(srcOffset, srcOffset + unalignedBytesPerRow), dstOffset); + } + } + + // Convert BGR to RGB for main canvas + this._convertBGRtoRGB(pixelData); const region = new Image(width, height); region.pixelDensity(pd); diff --git a/test/unit/webgpu/p5.Framebuffer.js b/test/unit/webgpu/p5.Framebuffer.js index 9fec2f070d..452585b6c8 100644 --- a/test/unit/webgpu/p5.Framebuffer.js +++ b/test/unit/webgpu/p5.Framebuffer.js @@ -1,4 +1,7 @@ import p5 from '../../../src/app.js'; +import rendererWebGPU from "../../../src/webgpu/p5.RendererWebGPU"; + +p5.registerAddon(rendererWebGPU); suite('WebGPU p5.Framebuffer', function() { let myp5; @@ -9,7 +12,6 @@ suite('WebGPU p5.Framebuffer', function() { window.devicePixelRatio = 1; myp5 = new p5(function(p) { p.setup = function() {}; - p.draw = function() {}; }); }); @@ -153,16 +155,26 @@ suite('WebGPU p5.Framebuffer', function() { await myp5.createCanvas(10, 10, myp5.WEBGPU); const fbo = myp5.createFramebuffer(); - let drawCallbackExecuted = false; + myp5.background(0, 255, 0); + fbo.draw(() => { - drawCallbackExecuted = true; - myp5.background(255, 0, 0); - myp5.fill(0, 255, 0); - myp5.noStroke(); - myp5.circle(5, 5, 8); + myp5.background(0, 0, 255); + // myp5.fill(0, 255, 0); }); - - expect(drawCallbackExecuted).to.equal(true); + await myp5.loadPixels(); + // Drawing should have gone to the framebuffer, leaving the main + // canvas the same + expect([...myp5.pixels.slice(0, 3)]).toEqual([0, 255, 0]); + await fbo.loadPixels(); + // The framebuffer should have content + expect([...fbo.pixels.slice(0, 3)]).toEqual([0, 0, 255]); + + // The content can be drawn back to the main canvas + myp5.imageMode(myp5.CENTER); + myp5.image(fbo, 0, 0); + await myp5.loadPixels(); + expect([...fbo.pixels.slice(0, 3)]).toEqual([0, 0, 255]); + expect([...myp5.pixels.slice(0, 3)]).toEqual([0, 0, 255]); }); test('can use framebuffer as texture', async function() { @@ -194,8 +206,9 @@ suite('WebGPU p5.Framebuffer', function() { expect(result).to.be.a('promise'); const pixels = await result; - expect(pixels).to.be.an('array'); + expect(pixels).toBeInstanceOf(Uint8Array); expect(pixels.length).to.equal(10 * 10 * 4); + expect([...pixels.slice(0, 4)]).toEqual([255, 0, 0, 255]); }); test('pixels property is set after loadPixels resolves', async function() { @@ -225,6 +238,7 @@ suite('WebGPU p5.Framebuffer', function() { const color = await result; expect(color).to.be.an('array'); expect(color).to.have.length(4); + expect([...color]).toEqual([100, 150, 200, 255]); }); test('get() returns a promise for region in WebGPU', async function() { @@ -242,6 +256,7 @@ suite('WebGPU p5.Framebuffer', function() { expect(region).to.be.an('object'); // Should be a p5.Image expect(region.width).to.equal(4); expect(region.height).to.equal(4); + expect([...region.pixels.slice(0, 4)]).toEqual([100, 150, 200, 255]); }); }); }); From 9fc319f996db258fbd6839ec5afb65682970d4f1 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Tue, 29 Jul 2025 11:32:18 -0400 Subject: [PATCH 38/72] Start adding tests --- test/unit/visual/cases/webgpu.js | 11 +++++++---- .../000.png | Bin 0 -> 132 bytes .../metadata.json | 3 +++ .../Framebuffers/Auto-sized framebuffer/000.png | Bin 0 -> 396 bytes .../Auto-sized framebuffer/metadata.json | 3 +++ .../Basic framebuffer draw to canvas/000.png | Bin 0 -> 521 bytes .../metadata.json | 3 +++ .../000.png | Bin 0 -> 291 bytes .../metadata.json | 3 +++ .../Framebuffer with different sizes/000.png | Bin 0 -> 402 bytes .../metadata.json | 3 +++ 11 files changed, 22 insertions(+), 4 deletions(-) create mode 100644 test/unit/visual/screenshots/WebGPU/Framebuffers/Auto-sized framebuffer after canvas resize/000.png create mode 100644 test/unit/visual/screenshots/WebGPU/Framebuffers/Auto-sized framebuffer after canvas resize/metadata.json create mode 100644 test/unit/visual/screenshots/WebGPU/Framebuffers/Auto-sized framebuffer/000.png create mode 100644 test/unit/visual/screenshots/WebGPU/Framebuffers/Auto-sized framebuffer/metadata.json create mode 100644 test/unit/visual/screenshots/WebGPU/Framebuffers/Basic framebuffer draw to canvas/000.png create mode 100644 test/unit/visual/screenshots/WebGPU/Framebuffers/Basic framebuffer draw to canvas/metadata.json create mode 100644 test/unit/visual/screenshots/WebGPU/Framebuffers/Fixed-size framebuffer after manual resize/000.png create mode 100644 test/unit/visual/screenshots/WebGPU/Framebuffers/Fixed-size framebuffer after manual resize/metadata.json create mode 100644 test/unit/visual/screenshots/WebGPU/Framebuffers/Framebuffer with different sizes/000.png create mode 100644 test/unit/visual/screenshots/WebGPU/Framebuffers/Framebuffer with different sizes/metadata.json diff --git a/test/unit/visual/cases/webgpu.js b/test/unit/visual/cases/webgpu.js index 334abc1be1..5613d39091 100644 --- a/test/unit/visual/cases/webgpu.js +++ b/test/unit/visual/cases/webgpu.js @@ -130,7 +130,7 @@ visualSuite("WebGPU", function () { p5.background(255, 0, 0); // Red background p5.fill(0, 255, 0); // Green circle p5.noStroke(); - p5.circle(12.5, 12.5, 20); + p5.circle(0, 0, 20); }); // Draw the framebuffer to the main canvas @@ -157,7 +157,7 @@ visualSuite("WebGPU", function () { p5.background(255, 100, 100); p5.fill(255, 255, 0); p5.noStroke(); - p5.rect(5, 5, 10, 10); + p5.rect(-5, -5, 10, 10); }); // Draw to second framebuffer @@ -165,7 +165,7 @@ visualSuite("WebGPU", function () { p5.background(100, 255, 100); p5.fill(255, 0, 255); p5.noStroke(); - p5.circle(7.5, 7.5, 10); + p5.circle(0, 0, 10); }); // Draw both to main canvas @@ -197,6 +197,7 @@ visualSuite("WebGPU", function () { // Draw to the framebuffer fbo.draw(() => { p5.background(0); + p5.translate(-fbo.width / 2, -fbo.height / 2) p5.stroke(255); p5.strokeWeight(2); p5.noFill(); @@ -234,6 +235,7 @@ visualSuite("WebGPU", function () { // Draw to the framebuffer after resize fbo.draw(() => { p5.background(100, 0, 100); + p5.translate(-fbo.width / 2, -fbo.height / 2) p5.fill(0, 255, 255); p5.noStroke(); // Draw a shape that fills the new size @@ -264,7 +266,7 @@ visualSuite("WebGPU", function () { p5.background(255, 200, 100); p5.fill(0, 100, 200); p5.noStroke(); - p5.circle(10, 10, 15); + p5.circle(0, 0, 15); }); // Manually resize the framebuffer @@ -273,6 +275,7 @@ visualSuite("WebGPU", function () { // Draw new content to the resized framebuffer fbo.draw(() => { p5.background(200, 255, 100); + p5.translate(-fbo.width / 2, -fbo.height / 2) p5.fill(200, 0, 100); p5.noStroke(); // Draw content that uses the new size diff --git a/test/unit/visual/screenshots/WebGPU/Framebuffers/Auto-sized framebuffer after canvas resize/000.png b/test/unit/visual/screenshots/WebGPU/Framebuffers/Auto-sized framebuffer after canvas resize/000.png new file mode 100644 index 0000000000000000000000000000000000000000..972571631e30265fef58735d0666a400049f596e GIT binary patch literal 132 zcmeAS@N?(olHy`uVBq!ia0vp^av;pX1|+Qw)-3{3jKx9jP7LeL$-D$|>^xl@Lp;3S zUf#&dV8Fp*c%we^*B5@*GYkrm?$Px$MoC0LRA@u(Spg1%FbIU7xtBR#Hh;!OQ;k_{hm~q|XJWEMHaWQC(6NXJGgIym zk#M=yxR4uwthDf~&Y>eIJ)t9s%2*2nYJpm@0#?dc<7R4Q-S8q8gCSld*KC?__O-O~B3B zp9{*+kCzz~sWmdwBU6=AG!hoJX>BtCQNX$PH-`fW-6#a29np!lXXSC5T6=)#6w~hm zNUVU1*wyr2cc50lPza@v9p^N*`NR894i`oqb7#+|sC?(bZZSYdw!F`G3hZ zzE)v!ly>ov=?2!_$UDw$MF9|}|1Qyyl<2&BKD1+58M<1l`k q9lxMfp*XGFx5nL1BP|--)|}p3hPVXA^49VI0000Px$!%0LzRA@u(m=DduFc8Jx9N+*PfCF#<4wwUQKyUyKh68W_4!{98z`@8%v#@;M zn(pu2SniT$BxP%V_x7&sA|OpMfQSsFXDXT^Jre|`h$+%h2JF@9D6zF4Yl_rPT}L-o zRwQM`?l>Y;-~Jg$NR;;o$hXlOm^=YqL!qt+Cw`x~_8J$1Odr7F>Y#j~3i6x)=M`{S zIuJq?1px>$zxjdGKnQV)fn*KvcbOnid;-?Ve|1zq>Of?mJ{T2{BnI}o@=E+wi!hrb z5GUV4APzz`2tk-8LC@^=&NB>gkfv&6@GQv$*&Y-?Se2;8VcSHD@4bW|45367VTmOO zOKuxMj}U~O5e2y}90;#%#OmxoX7?Wl$s1l&k+xqgHAMzvcKU)9i$3w~tIB$Z2B$x0 zc$IaeEyL$4mI?@!;G%fkG~1%&)ldQ9po7^o)`jWaHa;^nm7!;Db9eQfOSlE$qPM&N zEV)G|MbUM{SCKNH$JD{lN1S}t#xDoL<~zUl0we<@KoQ7Ke0(UoZiN0PqDW{GTK$zj z&}mIHG_HY!G9=w^ib!WXG@dCE%8+!wDI%Tm(0HauC_~cygHhxIAOktx0yLiA00000 LNkvXXu0mjfpm^EI literal 0 HcmV?d00001 diff --git a/test/unit/visual/screenshots/WebGPU/Framebuffers/Basic framebuffer draw to canvas/metadata.json b/test/unit/visual/screenshots/WebGPU/Framebuffers/Basic framebuffer draw to canvas/metadata.json new file mode 100644 index 0000000000..2d4bfe30da --- /dev/null +++ b/test/unit/visual/screenshots/WebGPU/Framebuffers/Basic framebuffer draw to canvas/metadata.json @@ -0,0 +1,3 @@ +{ + "numScreenshots": 1 +} \ No newline at end of file diff --git a/test/unit/visual/screenshots/WebGPU/Framebuffers/Fixed-size framebuffer after manual resize/000.png b/test/unit/visual/screenshots/WebGPU/Framebuffers/Fixed-size framebuffer after manual resize/000.png new file mode 100644 index 0000000000000000000000000000000000000000..1fb817b6b53c94fe1a9aa8d3412e4fc2a846cb6d GIT binary patch literal 291 zcmV+;0o?wHP)Px#-AP12RA@u(n9&V_Fc^e?R;mNwJT8GQ;XF8iuB7m2gf`_kLz<6QdRP9tuL;nl zl-|+6)iKDyTBMPK6%sT;Bc=pE%M|QpTN>FMok^H&3>r&m_QAoZDQ3L2U7 zYx|6h5%JJ4DXRO3IDfC&AFbQ8!L|4(+Jf1CV36Ms7taWdRL}-mD`A0F1x1TXK?}50 zhF0{T1zMn$Pv9?Sq?y-v6HO)C{l7;_JDTn@NK@bJCQ2-}{QI@UDwxuU5S~R!BbHbN pQyLM%vq)*g602ZJBSLr<=?C8Wx=p=J5DWkS002ovPDHLkV1j}$d4K=_ literal 0 HcmV?d00001 diff --git a/test/unit/visual/screenshots/WebGPU/Framebuffers/Fixed-size framebuffer after manual resize/metadata.json b/test/unit/visual/screenshots/WebGPU/Framebuffers/Fixed-size framebuffer after manual resize/metadata.json new file mode 100644 index 0000000000..2d4bfe30da --- /dev/null +++ b/test/unit/visual/screenshots/WebGPU/Framebuffers/Fixed-size framebuffer after manual resize/metadata.json @@ -0,0 +1,3 @@ +{ + "numScreenshots": 1 +} \ No newline at end of file diff --git a/test/unit/visual/screenshots/WebGPU/Framebuffers/Framebuffer with different sizes/000.png b/test/unit/visual/screenshots/WebGPU/Framebuffers/Framebuffer with different sizes/000.png new file mode 100644 index 0000000000000000000000000000000000000000..155638a0c818aa5432045a7fc8a9c574122ea8e3 GIT binary patch literal 402 zcmV;D0d4+?P)Px$Oi4sRRA@u(n9UJ_Fc5{8GHk%yV*zRb&;=cESMPRU7f^s&0J+BoC<7;qGV;4l zjIzY##*k$4?R#&@B#=^;;K37k2a-$aRF{=fPS?X0s}*68fl{#|n1=?w|B1Ck0kACa z8r>EEdeIn1%UYoVh~DTvsRYQHU5`O<{d-4@XM`02U~93p9;e^lBMN7PGh&$#fbsV0 z&7a-6KGdwmAgqYB2mHHyur39WiL3|_A?j_gBCN=lD!|tIw6HR_ziU>j?#<3*ig*tq zE&{|=RD!3wv{!i2ibRJ9!hP7;c(%_vKx9FNAf8lh7_uOz;Dq*$xQy#DBtbAWp1VJu zkDxcYdYv`MtG!5qm@buU6VzJXfRN9Kkpj!fY`WGM%&h(+uK_XYm`AU5-KE+AA{llx wv^Nd9TV*yT%r+73jhEB07*qoM6N<$f)?JfjsO4v literal 0 HcmV?d00001 diff --git a/test/unit/visual/screenshots/WebGPU/Framebuffers/Framebuffer with different sizes/metadata.json b/test/unit/visual/screenshots/WebGPU/Framebuffers/Framebuffer with different sizes/metadata.json new file mode 100644 index 0000000000..2d4bfe30da --- /dev/null +++ b/test/unit/visual/screenshots/WebGPU/Framebuffers/Framebuffer with different sizes/metadata.json @@ -0,0 +1,3 @@ +{ + "numScreenshots": 1 +} \ No newline at end of file From 76c010898d0623ae5da5e7b383df936698165d82 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Tue, 29 Jul 2025 16:03:34 -0400 Subject: [PATCH 39/72] Add another test --- test/unit/visual/cases/webgpu.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/test/unit/visual/cases/webgpu.js b/test/unit/visual/cases/webgpu.js index 5613d39091..a57caa1ff1 100644 --- a/test/unit/visual/cases/webgpu.js +++ b/test/unit/visual/cases/webgpu.js @@ -116,6 +116,23 @@ visualSuite("WebGPU", function () { ); }); + visualSuite("Canvas Resizing", function () { + visualTest( + "Main canvas drawing after resize", + async function (p5, screenshot) { + await p5.createCanvas(50, 50, p5.WEBGPU); + // Resize the canvas + p5.resizeCanvas(30, 30); + // Draw to the main canvas after resize + p5.background(100, 0, 100); + p5.fill(0, 255, 255); + p5.noStroke(); + p5.circle(0, 0, 20); + await screenshot(); + }, + ); + }); + visualSuite("Framebuffers", function () { visualTest( "Basic framebuffer draw to canvas", @@ -251,6 +268,7 @@ visualSuite("WebGPU", function () { await screenshot(); }, + { focus: true } ); visualTest( From a3daa14cb606b36a67cbb1436cd49043f9be0ad2 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Tue, 29 Jul 2025 16:13:43 -0400 Subject: [PATCH 40/72] Fix main canvas not being drawable after resizing --- src/webgpu/p5.RendererWebGPU.js | 11 ++++++----- test/unit/visual/cases/webgpu.js | 1 - .../000.png | Bin 132 -> 167 bytes 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/webgpu/p5.RendererWebGPU.js b/src/webgpu/p5.RendererWebGPU.js index 383cf9f97f..f85fd4607b 100644 --- a/src/webgpu/p5.RendererWebGPU.js +++ b/src/webgpu/p5.RendererWebGPU.js @@ -66,6 +66,9 @@ class RendererWebGPU extends Renderer3D { usage: GPUTextureUsage.RENDER_ATTACHMENT, }); + // Clear the main canvas after resize + this.clear(); + // Destroy existing readback texture when size changes if (this.canvasReadbackTexture && this.canvasReadbackTexture.destroy) { this.canvasReadbackTexture.destroy(); @@ -1287,8 +1290,6 @@ class RendererWebGPU extends Renderer3D { if (framebuffer.aaDepthTexture && framebuffer.aaDepthTexture.destroy) { framebuffer.aaDepthTexture.destroy(); } - // Clear cached views when recreating textures - framebuffer._colorTextureView = null; const baseDescriptor = { size: { @@ -1389,10 +1390,10 @@ class RendererWebGPU extends Renderer3D { } _getFramebufferColorTextureView(framebuffer) { - if (!framebuffer._colorTextureView && framebuffer.colorTexture) { - framebuffer._colorTextureView = framebuffer.colorTexture.createView(); + if (framebuffer.colorTexture) { + return framebuffer.colorTexture.createView(); } - return framebuffer._colorTextureView; + return null; } createFramebufferTextureHandle(framebufferTexture) { diff --git a/test/unit/visual/cases/webgpu.js b/test/unit/visual/cases/webgpu.js index a57caa1ff1..28382dda25 100644 --- a/test/unit/visual/cases/webgpu.js +++ b/test/unit/visual/cases/webgpu.js @@ -268,7 +268,6 @@ visualSuite("WebGPU", function () { await screenshot(); }, - { focus: true } ); visualTest( diff --git a/test/unit/visual/screenshots/WebGPU/Framebuffers/Auto-sized framebuffer after canvas resize/000.png b/test/unit/visual/screenshots/WebGPU/Framebuffers/Auto-sized framebuffer after canvas resize/000.png index 972571631e30265fef58735d0666a400049f596e..01be2eb74e88adf3364c6690d614b349cfa91f03 100644 GIT binary patch delta 125 zcmV-@0D}L70jB|wF?L}|L_t(YOJhu7Ncqn&0Dy7SVtR%8;0o$F|7TOx<0*`(80KO1 z@up(Xm%RSPNaswXaO=>fWXttXaPcM_CZ%qbatZDB4YFpu2v>7 fE~Zq?$n!A(ky>$a7wkmT00000NkvXXu0mjfc*i*f delta 90 zcmV-g0Hyz@0fYgNF;hNCL_t(YOYPIK4FE6*1ToluY5MdJMa%#oSx48=^wHgNcugKP w>X?AIVzlpK)TmDwV{wTqCh%We1L^kwA6Dmx82|tP07*qoM6N<$g69P$<^TWy From 01110c9f77923fd1b704b1a87efdd3152fed0567 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Tue, 29 Jul 2025 16:13:59 -0400 Subject: [PATCH 41/72] Add screenshots --- .../Main canvas drawing after resize/000.png | Bin 0 -> 225 bytes .../metadata.json | 3 +++ 2 files changed, 3 insertions(+) create mode 100644 test/unit/visual/screenshots/WebGPU/Canvas Resizing/Main canvas drawing after resize/000.png create mode 100644 test/unit/visual/screenshots/WebGPU/Canvas Resizing/Main canvas drawing after resize/metadata.json diff --git a/test/unit/visual/screenshots/WebGPU/Canvas Resizing/Main canvas drawing after resize/000.png b/test/unit/visual/screenshots/WebGPU/Canvas Resizing/Main canvas drawing after resize/000.png new file mode 100644 index 0000000000000000000000000000000000000000..96849ce04c21325da234ba7192bc8c1bc63bce67 GIT binary patch literal 225 zcmeAS@N?(olHy`uVBq!ia0vp^av;pX1|+Qw)-3{3jKx9jP7LeL$-D$|W_!9ghIn|t zofgfFo`syt)gx-5OIFIp(nbZ(PLshJD(tw9jF0d>^YO=xU_ Date: Tue, 29 Jul 2025 18:06:51 -0400 Subject: [PATCH 42/72] Try setting different launch options --- vitest.workspace.mjs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/vitest.workspace.mjs b/vitest.workspace.mjs index 7dfe0e6e82..e11dd11c53 100644 --- a/vitest.workspace.mjs +++ b/vitest.workspace.mjs @@ -38,7 +38,15 @@ export default defineWorkspace([ enabled: true, name: 'chrome', provider: 'webdriverio', - screenshotFailures: false + screenshotFailures: false, + launchOptions: { + args: [ + '--enable-unsafe-webgpu', + '--headless=new', + '--disable-gpu-sandbox', + '--no-sandbox', + ], + }, } } } From 02eef85af474bae627f5d4d8492b0db648b9da19 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Tue, 29 Jul 2025 18:13:01 -0400 Subject: [PATCH 43/72] Test different options --- vitest.workspace.mjs | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/vitest.workspace.mjs b/vitest.workspace.mjs index e11dd11c53..636e7a8db3 100644 --- a/vitest.workspace.mjs +++ b/vitest.workspace.mjs @@ -39,14 +39,17 @@ export default defineWorkspace([ name: 'chrome', provider: 'webdriverio', screenshotFailures: false, - launchOptions: { - args: [ - '--enable-unsafe-webgpu', - '--headless=new', - '--disable-gpu-sandbox', - '--no-sandbox', - ], - }, + providerOptions: { + capabilities: { + 'goog:chromeOptions': { + args: [ + '--enable-unsafe-webgpu', + '--enable-features=Vulkan', + '--disable-vulkan-fallback-to-gl-for-testing' + ] + } + } + } } } } From 3c6c19506f1543a7f07fa8abd77d586a2ca3e700 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Tue, 29 Jul 2025 18:31:00 -0400 Subject: [PATCH 44/72] Try sequential --- test/unit/visual/cases/webgpu.js | 2 +- test/unit/visual/visualTest.js | 5 ++++- test/unit/webgpu/p5.Framebuffer.js | 3 ++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/test/unit/visual/cases/webgpu.js b/test/unit/visual/cases/webgpu.js index 28382dda25..dde49a9d16 100644 --- a/test/unit/visual/cases/webgpu.js +++ b/test/unit/visual/cases/webgpu.js @@ -311,4 +311,4 @@ visualSuite("WebGPU", function () { }, ); }); -}); +}, { sequential: true }); diff --git a/test/unit/visual/visualTest.js b/test/unit/visual/visualTest.js index 7d301d142b..7841c5a84d 100644 --- a/test/unit/visual/visualTest.js +++ b/test/unit/visual/visualTest.js @@ -52,7 +52,7 @@ let shiftThreshold = 2; export function visualSuite( name, callback, - { focus = false, skip = false, shiftThreshold: newShiftThreshold } = {} + { focus = false, skip = false, sequential = false, shiftThreshold: newShiftThreshold } = {} ) { let suiteFn = describe; if (focus) { @@ -61,6 +61,9 @@ export function visualSuite( if (skip) { suiteFn = suiteFn.skip; } + if (sequential) { + suiteFn = suiteFn.sequential; + } suiteFn(name, () => { let lastShiftThreshold let lastPrefix; diff --git a/test/unit/webgpu/p5.Framebuffer.js b/test/unit/webgpu/p5.Framebuffer.js index 452585b6c8..08789e92d1 100644 --- a/test/unit/webgpu/p5.Framebuffer.js +++ b/test/unit/webgpu/p5.Framebuffer.js @@ -1,9 +1,10 @@ +import describe from '../../../src/accessibility/describe.js'; import p5 from '../../../src/app.js'; import rendererWebGPU from "../../../src/webgpu/p5.RendererWebGPU"; p5.registerAddon(rendererWebGPU); -suite('WebGPU p5.Framebuffer', function() { +suite.sequential('WebGPU p5.Framebuffer', function() { let myp5; let prevPixelRatio; From e4509570a43e8744ab8ab17e68fc6fd464eed227 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Tue, 29 Jul 2025 19:15:43 -0400 Subject: [PATCH 45/72] Attempt to install later chrome --- .github/workflows/ci-test.yml | 9 +++++++++ test/unit/visual/cases/webgpu.js | 2 +- test/unit/visual/visualTest.js | 5 +---- test/unit/webgpu/p5.Framebuffer.js | 3 +-- vitest.workspace.mjs | 10 +++++++--- 5 files changed, 19 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index bd3ca55ba0..31d8e36566 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -19,6 +19,15 @@ jobs: uses: actions/setup-node@v1 with: node-version: 20.x + - name: Install Chrome (latest stable) + run: | + sudo apt-get update + sudo apt-get install -y wget gnupg + wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | sudo apt-key add - + sudo sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google-chrome.list' + sudo apt-get update + sudo apt-get install -y google-chrome-stable + which google-chrome - name: Get node modules run: npm ci env: diff --git a/test/unit/visual/cases/webgpu.js b/test/unit/visual/cases/webgpu.js index dde49a9d16..28382dda25 100644 --- a/test/unit/visual/cases/webgpu.js +++ b/test/unit/visual/cases/webgpu.js @@ -311,4 +311,4 @@ visualSuite("WebGPU", function () { }, ); }); -}, { sequential: true }); +}); diff --git a/test/unit/visual/visualTest.js b/test/unit/visual/visualTest.js index 7841c5a84d..7d301d142b 100644 --- a/test/unit/visual/visualTest.js +++ b/test/unit/visual/visualTest.js @@ -52,7 +52,7 @@ let shiftThreshold = 2; export function visualSuite( name, callback, - { focus = false, skip = false, sequential = false, shiftThreshold: newShiftThreshold } = {} + { focus = false, skip = false, shiftThreshold: newShiftThreshold } = {} ) { let suiteFn = describe; if (focus) { @@ -61,9 +61,6 @@ export function visualSuite( if (skip) { suiteFn = suiteFn.skip; } - if (sequential) { - suiteFn = suiteFn.sequential; - } suiteFn(name, () => { let lastShiftThreshold let lastPrefix; diff --git a/test/unit/webgpu/p5.Framebuffer.js b/test/unit/webgpu/p5.Framebuffer.js index 08789e92d1..452585b6c8 100644 --- a/test/unit/webgpu/p5.Framebuffer.js +++ b/test/unit/webgpu/p5.Framebuffer.js @@ -1,10 +1,9 @@ -import describe from '../../../src/accessibility/describe.js'; import p5 from '../../../src/app.js'; import rendererWebGPU from "../../../src/webgpu/p5.RendererWebGPU"; p5.registerAddon(rendererWebGPU); -suite.sequential('WebGPU p5.Framebuffer', function() { +suite('WebGPU p5.Framebuffer', function() { let myp5; let prevPixelRatio; diff --git a/vitest.workspace.mjs b/vitest.workspace.mjs index 636e7a8db3..611754fcdc 100644 --- a/vitest.workspace.mjs +++ b/vitest.workspace.mjs @@ -40,15 +40,19 @@ export default defineWorkspace([ provider: 'webdriverio', screenshotFailures: false, providerOptions: { - capabilities: { + capabilities: process.env.CI ? { 'goog:chromeOptions': { + binary: '/usr/bin/google-chrome', args: [ '--enable-unsafe-webgpu', + '--disable-dawn-features=disallow_unsafe_apis', + '--use-angle=default', '--enable-features=Vulkan', - '--disable-vulkan-fallback-to-gl-for-testing' + '--no-sandbox', + '--disable-dev-shm-usage', ] } - } + } : undefined } } } From f44629bac26cbc86c0eb04aca1549f19056da40e Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Tue, 29 Jul 2025 19:23:01 -0400 Subject: [PATCH 46/72] Add some debug info --- .github/workflows/ci-test.yml | 1 + vitest.workspace.mjs | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index 31d8e36566..6a1bd0b549 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -28,6 +28,7 @@ jobs: sudo apt-get update sudo apt-get install -y google-chrome-stable which google-chrome + google-chrome --version - name: Get node modules run: npm ci env: diff --git a/vitest.workspace.mjs b/vitest.workspace.mjs index 611754fcdc..2774fe1286 100644 --- a/vitest.workspace.mjs +++ b/vitest.workspace.mjs @@ -1,5 +1,6 @@ import { defineWorkspace } from 'vitest/config'; import vitePluginString from 'vite-plugin-string'; +console.log(`CI: ${process.env.CI}`) const plugins = [ vitePluginString({ From b281d332b5acfecacc32b3372f0929e2dc7ea226 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Tue, 29 Jul 2025 19:39:48 -0400 Subject: [PATCH 47/72] Try different flags --- vitest.workspace.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/vitest.workspace.mjs b/vitest.workspace.mjs index 2774fe1286..1aa54097e5 100644 --- a/vitest.workspace.mjs +++ b/vitest.workspace.mjs @@ -48,9 +48,10 @@ export default defineWorkspace([ '--enable-unsafe-webgpu', '--disable-dawn-features=disallow_unsafe_apis', '--use-angle=default', - '--enable-features=Vulkan', + '--enable-features=Vulkan,SharedArrayBuffer', '--no-sandbox', '--disable-dev-shm-usage', + '--headless=new', ] } } : undefined From 777334131df1ad9e122aaf809911909e71e91175 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Tue, 29 Jul 2025 19:47:19 -0400 Subject: [PATCH 48/72] Does it work in xvfb? --- .github/workflows/ci-test.yml | 2 +- vitest.workspace.mjs | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index 6a1bd0b549..67684ad745 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -34,7 +34,7 @@ jobs: env: CI: true - name: build and test - run: npm test + run: xvfb-run --auto-servernum --server-args='-screen 0 1920x1080x24' npm test env: CI: true - name: report test coverage diff --git a/vitest.workspace.mjs b/vitest.workspace.mjs index 1aa54097e5..9540289d24 100644 --- a/vitest.workspace.mjs +++ b/vitest.workspace.mjs @@ -46,12 +46,11 @@ export default defineWorkspace([ binary: '/usr/bin/google-chrome', args: [ '--enable-unsafe-webgpu', - '--disable-dawn-features=disallow_unsafe_apis', - '--use-angle=default', '--enable-features=Vulkan,SharedArrayBuffer', + '--disable-dawn-features=disallow_unsafe_apis', + '--disable-gpu-sandbox', '--no-sandbox', - '--disable-dev-shm-usage', - '--headless=new', + '--disable-dev-shm-usage' ] } } : undefined From cfeac932d62f32ea7cca9c88c52532d4a8432807 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Thu, 31 Jul 2025 20:42:15 -0400 Subject: [PATCH 49/72] Try enabling swiftshader --- vitest.workspace.mjs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/vitest.workspace.mjs b/vitest.workspace.mjs index 9540289d24..d39212ef8e 100644 --- a/vitest.workspace.mjs +++ b/vitest.workspace.mjs @@ -45,12 +45,14 @@ export default defineWorkspace([ 'goog:chromeOptions': { binary: '/usr/bin/google-chrome', args: [ - '--enable-unsafe-webgpu', - '--enable-features=Vulkan,SharedArrayBuffer', '--disable-dawn-features=disallow_unsafe_apis', '--disable-gpu-sandbox', '--no-sandbox', - '--disable-dev-shm-usage' + '--disable-dev-shm-usage', + + '--enable-unsafe-webgpu', + '--use-angle=swiftshader', + '--enable-features=ReduceOpsTaskSplitting,Vulkan,VulkanFromANGLE,DefaultANGLEVulkan', ] } } : undefined From af5194a25a34ea21d2ba24c5b64ba99149cee4f4 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Thu, 31 Jul 2025 20:46:35 -0400 Subject: [PATCH 50/72] less flags --- vitest.workspace.mjs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/vitest.workspace.mjs b/vitest.workspace.mjs index d39212ef8e..7fadc2434e 100644 --- a/vitest.workspace.mjs +++ b/vitest.workspace.mjs @@ -45,11 +45,6 @@ export default defineWorkspace([ 'goog:chromeOptions': { binary: '/usr/bin/google-chrome', args: [ - '--disable-dawn-features=disallow_unsafe_apis', - '--disable-gpu-sandbox', - '--no-sandbox', - '--disable-dev-shm-usage', - '--enable-unsafe-webgpu', '--use-angle=swiftshader', '--enable-features=ReduceOpsTaskSplitting,Vulkan,VulkanFromANGLE,DefaultANGLEVulkan', From 88b4fe4d31f604be349493cbb569f10b40b9d569 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Thu, 31 Jul 2025 20:53:42 -0400 Subject: [PATCH 51/72] Try disabling dawn --- vitest.workspace.mjs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/vitest.workspace.mjs b/vitest.workspace.mjs index 7fadc2434e..4220f9aa26 100644 --- a/vitest.workspace.mjs +++ b/vitest.workspace.mjs @@ -46,8 +46,13 @@ export default defineWorkspace([ binary: '/usr/bin/google-chrome', args: [ '--enable-unsafe-webgpu', - '--use-angle=swiftshader', - '--enable-features=ReduceOpsTaskSplitting,Vulkan,VulkanFromANGLE,DefaultANGLEVulkan', + '--enable-features=Vulkan', + '--use-cmd-decoder=passthrough', + '--disable-gpu-sandbox', + '--disable-software-rasterizer=false', + '--disable-dawn-features=disallow_unsafe_apis', + '--use-angle=vulkan', + '--use-vulkan=swiftshader', ] } } : undefined From ff83226f86cd39817fea848732fc4bcb46cac391 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Fri, 1 Aug 2025 08:05:42 -0400 Subject: [PATCH 52/72] Try installing swiftshader? --- .github/workflows/ci-test.yml | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index 67684ad745..4e4c749ca1 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -19,6 +19,17 @@ jobs: uses: actions/setup-node@v1 with: node-version: 20.x + - name: Install Vulkan SwiftShader + run: | + sudo apt-get update + sudo apt-get install -y libvulkan1 vulkan-tools mesa-vulkan-drivers + mkdir -p $HOME/swiftshader + curl -L https://github.com/google/swiftshader/releases/download/latest/Linux.zip -o swiftshader.zip + unzip swiftshader.zip -d $HOME/swiftshader + export VK_ICD_FILENAMES=$HOME/swiftshader/Linux/vk_swiftshader_icd.json + export VK_LAYER_PATH=$HOME/swiftshader/Linux + echo "VK_ICD_FILENAMES=$VK_ICD_FILENAMES" >> $GITHUB_ENV + echo "VK_LAYER_PATH=$VK_LAYER_PATH" >> $GITHUB_ENV - name: Install Chrome (latest stable) run: | sudo apt-get update @@ -34,7 +45,7 @@ jobs: env: CI: true - name: build and test - run: xvfb-run --auto-servernum --server-args='-screen 0 1920x1080x24' npm test + run: npm test env: CI: true - name: report test coverage From 4314cf2d21d860cd0e41ae993b127c1c79bca201 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Fri, 1 Aug 2025 08:11:57 -0400 Subject: [PATCH 53/72] Just vulkan --- .github/workflows/ci-test.yml | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index 4e4c749ca1..12a6cde3b9 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -19,17 +19,10 @@ jobs: uses: actions/setup-node@v1 with: node-version: 20.x - - name: Install Vulkan SwiftShader + - name: Install Vulkan run: | sudo apt-get update sudo apt-get install -y libvulkan1 vulkan-tools mesa-vulkan-drivers - mkdir -p $HOME/swiftshader - curl -L https://github.com/google/swiftshader/releases/download/latest/Linux.zip -o swiftshader.zip - unzip swiftshader.zip -d $HOME/swiftshader - export VK_ICD_FILENAMES=$HOME/swiftshader/Linux/vk_swiftshader_icd.json - export VK_LAYER_PATH=$HOME/swiftshader/Linux - echo "VK_ICD_FILENAMES=$VK_ICD_FILENAMES" >> $GITHUB_ENV - echo "VK_LAYER_PATH=$VK_LAYER_PATH" >> $GITHUB_ENV - name: Install Chrome (latest stable) run: | sudo apt-get update From c01dee7285d2a90e5e535c9e2067c3c8b0307b23 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Fri, 1 Aug 2025 08:36:49 -0400 Subject: [PATCH 54/72] Try with xvfb --- .github/workflows/ci-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index 12a6cde3b9..5289728040 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -38,7 +38,7 @@ jobs: env: CI: true - name: build and test - run: npm test + run: xvfb-run --auto-servernum --server-args='-screen 0 1280x1024x24' npm test env: CI: true - name: report test coverage From 7b3ed67261ce104e8ec1ad21aa7e5fafb2dc5dd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?charlotte=20=F0=9F=8C=B8?= Date: Wed, 6 Aug 2025 12:05:41 -0700 Subject: [PATCH 55/72] Test ci flow with warp. --- .github/workflows/ci-test.yml | 22 ++++++---------------- vitest.workspace.mjs | 14 ++++++-------- 2 files changed, 12 insertions(+), 24 deletions(-) diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index 5289728040..0d3569d0a4 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -11,34 +11,24 @@ on: jobs: test: - runs-on: ubuntu-latest + runs-on: windows-latest steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v4 - name: Use Node.js 20.x - uses: actions/setup-node@v1 + uses: actions/setup-node@v4 with: node-version: 20.x - - name: Install Vulkan - run: | - sudo apt-get update - sudo apt-get install -y libvulkan1 vulkan-tools mesa-vulkan-drivers - name: Install Chrome (latest stable) run: | - sudo apt-get update - sudo apt-get install -y wget gnupg - wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | sudo apt-key add - - sudo sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google-chrome.list' - sudo apt-get update - sudo apt-get install -y google-chrome-stable - which google-chrome - google-chrome --version + choco install googlechrome + & "C:\Program Files\Google\Chrome\Application\chrome.exe" --version - name: Get node modules run: npm ci env: CI: true - name: build and test - run: xvfb-run --auto-servernum --server-args='-screen 0 1280x1024x24' npm test + run: npm test env: CI: true - name: report test coverage diff --git a/vitest.workspace.mjs b/vitest.workspace.mjs index 4220f9aa26..943943eabd 100644 --- a/vitest.workspace.mjs +++ b/vitest.workspace.mjs @@ -43,16 +43,14 @@ export default defineWorkspace([ providerOptions: { capabilities: process.env.CI ? { 'goog:chromeOptions': { - binary: '/usr/bin/google-chrome', args: [ '--enable-unsafe-webgpu', - '--enable-features=Vulkan', - '--use-cmd-decoder=passthrough', - '--disable-gpu-sandbox', - '--disable-software-rasterizer=false', - '--disable-dawn-features=disallow_unsafe_apis', - '--use-angle=vulkan', - '--use-vulkan=swiftshader', + '--headless=new', + '--no-sandbox', + '--disable-dev-shm-usage', + '--use-gl=angle', + '--use-angle=d3d11-warp', + '--disable-gpu-sandbox' ] } } : undefined From 22f5294a35bc6a8ec9f6942dd173e15b9ed91a3f Mon Sep 17 00:00:00 2001 From: charlotte Date: Thu, 7 Aug 2025 16:44:09 -0700 Subject: [PATCH 56/72] Fixes for CI. --- .github/workflows/ci-test.yml | 41 ++++++++++++++++++---- src/webgpu/p5.RendererWebGPU.js | 2 +- src/webgpu/shaders/utils.js | 4 +-- vitest.workspace.mjs | 61 ++++++++++++++++++++++++++++----- 4 files changed, 90 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index 0d3569d0a4..416d01777d 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -11,27 +11,54 @@ on: jobs: test: - runs-on: windows-latest + strategy: + matrix: + include: + - os: windows-latest + browser: firefox + test-workspace: unit-tests-firefox + - os: ubuntu-latest + browser: chrome + test-workspace: unit-tests-chrome + + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 + - name: Use Node.js 20.x uses: actions/setup-node@v4 with: node-version: 20.x - - name: Install Chrome (latest stable) + + - name: Verify Firefox (Windows) + if: matrix.os == 'windows-latest' && matrix.browser == 'firefox' run: | - choco install googlechrome - & "C:\Program Files\Google\Chrome\Application\chrome.exe" --version + & "C:\Program Files\Mozilla Firefox\firefox.exe" --version + + - name: Verify Chrome (Ubuntu) + if: matrix.os == 'ubuntu-latest' && matrix.browser == 'chrome' + run: | + google-chrome --version + - name: Get node modules run: npm ci env: CI: true - - name: build and test - run: npm test + + - name: Build and test (Ubuntu) + if: matrix.os == 'ubuntu-latest' + run: npm test -- --project=${{ matrix.test-workspace }} + env: + CI: true + + - name: Build and test (Windows) + if: matrix.os == 'windows-latest' + run: npm test -- --project=${{ matrix.test-workspace }} env: CI: true - - name: report test coverage + + - name: Report test coverage run: bash <(curl -s https://codecov.io/bash) -f coverage/coverage-final.json env: CI: true diff --git a/src/webgpu/p5.RendererWebGPU.js b/src/webgpu/p5.RendererWebGPU.js index f85fd4607b..2a4a9b3e8d 100644 --- a/src/webgpu/p5.RendererWebGPU.js +++ b/src/webgpu/p5.RendererWebGPU.js @@ -631,7 +631,7 @@ class RendererWebGPU extends Renderer3D { ////////////////////////////////////////////// _drawBuffers(geometry, { mode = constants.TRIANGLES, count = 1 }) { - const buffers = this.geometryBufferCache.getCached(geometry); + const buffers = this.geometryBufferCache.ensureCached(geometry); if (!buffers) return; const commandEncoder = this.device.createCommandEncoder(); diff --git a/src/webgpu/shaders/utils.js b/src/webgpu/shaders/utils.js index 0a313dfaf8..a6b79426e9 100644 --- a/src/webgpu/shaders/utils.js +++ b/src/webgpu/shaders/utils.js @@ -1,6 +1,6 @@ export const getTexture = ` -fn getTexture(texture: texture_2d, sampler: sampler, coord: vec2) -> vec4 { - let color = textureSample(texture, sampler, coord); +fn getTexture(texture: texture_2d, texSampler: sampler, coord: vec2) -> vec4 { + let color = textureSample(texture, texSampler, coord); let alpha = color.a; return vec4( select(color.rgb / alpha, vec3(0.0), alpha == 0.0), diff --git a/vitest.workspace.mjs b/vitest.workspace.mjs index 943943eabd..23055b6680 100644 --- a/vitest.workspace.mjs +++ b/vitest.workspace.mjs @@ -23,7 +23,7 @@ export default defineWorkspace([ ], }, test: { - name: 'unit', + name: 'unit-tests-chrome', root: './', include: [ './test/unit/**/*.js', @@ -33,7 +33,7 @@ export default defineWorkspace([ './test/unit/assets/**/*', './test/unit/visual/visualTest.js', ], - testTimeout: 1000, + testTimeout: 10000, globals: true, browser: { enabled: true, @@ -44,18 +44,63 @@ export default defineWorkspace([ capabilities: process.env.CI ? { 'goog:chromeOptions': { args: [ - '--enable-unsafe-webgpu', - '--headless=new', '--no-sandbox', - '--disable-dev-shm-usage', - '--use-gl=angle', - '--use-angle=d3d11-warp', - '--disable-gpu-sandbox' + '--headless=new', + '--use-angle=vulkan', + '--enable-features=Vulkan', + '--disable-vulkan-surface', + '--enable-unsafe-webgpu', ] } } : undefined } } } + }, + { + plugins, + publicDir: './test', + bench: { + name: 'bench', + root: './', + include: [ + './test/bench/**/*.js' + ], + }, + test: { + name: 'unit-tests-firefox', + root: './', + include: [ + './test/unit/**/*.js', + ], + exclude: [ + './test/unit/spec.js', + './test/unit/assets/**/*', + './test/unit/visual/visualTest.js', + ], + testTimeout: 10000, + globals: true, + browser: { + enabled: true, + name: 'firefox', + provider: 'webdriverio', + screenshotFailures: false, + providerOptions: { + capabilities: process.env.CI ? { + 'moz:firefoxOptions': { + args: [ + '--headless', + '--enable-webgpu', + ], + prefs: { + 'dom.webgpu.enabled': true, + 'gfx.webgpu.force-enabled': true, + 'dom.webgpu.testing.assert-on-warnings': false, + } + } + } : undefined + } + } + } } ]); \ No newline at end of file From a8dea1506b25e776d02024855bce3d5dd23c4dcb Mon Sep 17 00:00:00 2001 From: charlotte Date: Thu, 7 Aug 2025 16:48:56 -0700 Subject: [PATCH 57/72] Revert change. --- src/webgpu/p5.RendererWebGPU.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/webgpu/p5.RendererWebGPU.js b/src/webgpu/p5.RendererWebGPU.js index 2a4a9b3e8d..f85fd4607b 100644 --- a/src/webgpu/p5.RendererWebGPU.js +++ b/src/webgpu/p5.RendererWebGPU.js @@ -631,7 +631,7 @@ class RendererWebGPU extends Renderer3D { ////////////////////////////////////////////// _drawBuffers(geometry, { mode = constants.TRIANGLES, count = 1 }) { - const buffers = this.geometryBufferCache.ensureCached(geometry); + const buffers = this.geometryBufferCache.getCached(geometry); if (!buffers) return; const commandEncoder = this.device.createCommandEncoder(); From 9c49827445dfdcd1bf425d5bcda7a23c620331f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?charlotte=20=F0=9F=8C=B8?= Date: Fri, 8 Aug 2025 14:40:02 -0700 Subject: [PATCH 58/72] Add setAttributes api to WebGPU renderer. --- src/core/main.js | 1 + src/core/p5.Renderer3D.js | 75 ++++++++++++++++++++++++++++++ src/webgl/p5.RendererGL.js | 73 +---------------------------- src/webgpu/p5.RendererWebGPU.js | 71 +++++++++++++++++++++++++++- test/unit/visual/cases/webgpu.js | 38 +++++++++++++-- test/unit/webgpu/p5.Framebuffer.js | 22 +++------ 6 files changed, 187 insertions(+), 93 deletions(-) diff --git a/src/core/main.js b/src/core/main.js index f9fc1c6559..9a3d929f3f 100644 --- a/src/core/main.js +++ b/src/core/main.js @@ -58,6 +58,7 @@ class p5 { this._curElement = null; this._elements = []; this._glAttributes = null; + this._webgpuAttributes = null; this._requestAnimId = 0; this._isGlobal = false; this._loop = true; diff --git a/src/core/p5.Renderer3D.js b/src/core/p5.Renderer3D.js index e422c2940f..bbf42b330c 100644 --- a/src/core/p5.Renderer3D.js +++ b/src/core/p5.Renderer3D.js @@ -1,4 +1,5 @@ import * as constants from "../core/constants"; +import { Graphics } from "../core/p5.Graphics"; import { Renderer } from './p5.Renderer'; import GeometryBuilder from "../webgl/GeometryBuilder"; import { Matrix } from "../math/p5.Matrix"; @@ -350,6 +351,80 @@ export class Renderer3D extends Renderer { }; } + //This is helper function to reset the context anytime the attributes + //are changed with setAttributes() + + async _resetContext(options, callback, ctor = Renderer3D) { + const w = this.width; + const h = this.height; + const defaultId = this.canvas.id; + const isPGraphics = this._pInst instanceof Graphics; + + // Preserve existing position and styles before recreation + const prevStyle = { + position: this.canvas.style.position, + top: this.canvas.style.top, + left: this.canvas.style.left, + }; + + if (isPGraphics) { + // Handle PGraphics: remove and recreate the canvas + const pg = this._pInst; + pg.canvas.parentNode.removeChild(pg.canvas); + pg.canvas = document.createElement("canvas"); + const node = pg._pInst._userNode || document.body; + node.appendChild(pg.canvas); + Element.call(pg, pg.canvas, pg._pInst); + // Restore previous width and height + pg.width = w; + pg.height = h; + } else { + // Handle main canvas: remove and recreate it + let c = this.canvas; + if (c) { + c.parentNode.removeChild(c); + } + c = document.createElement("canvas"); + c.id = defaultId; + // Attach the new canvas to the correct parent node + if (this._pInst._userNode) { + this._pInst._userNode.appendChild(c); + } else { + document.body.appendChild(c); + } + this._pInst.canvas = c; + this.canvas = c; + + // Restore the saved position + this.canvas.style.position = prevStyle.position; + this.canvas.style.top = prevStyle.top; + this.canvas.style.left = prevStyle.left; + } + + const renderer = new ctor( + this._pInst, + w, + h, + !isPGraphics, + this._pInst.canvas + ); + this._pInst._renderer = renderer; + + renderer._applyDefaults(); + + if (renderer.contextReady) { + await renderer.contextReady + } + + if (typeof callback === "function") { + //setTimeout with 0 forces the task to the back of the queue, this ensures that + //we finish switching out the renderer + setTimeout(() => { + callback.apply(window._renderer, options); + }, 0); + } + } + remove() { this.wrappedElt.remove(); this.wrappedElt = null; diff --git a/src/webgl/p5.RendererGL.js b/src/webgl/p5.RendererGL.js index c6fbfa45a6..ab15ea3d81 100644 --- a/src/webgl/p5.RendererGL.js +++ b/src/webgl/p5.RendererGL.js @@ -13,7 +13,6 @@ import { Renderer3D, getStrokeDefs } from "../core/p5.Renderer3D"; import { Shader } from "./p5.Shader"; import { Texture, MipmapTexture } from "./p5.Texture"; import { Framebuffer } from "./p5.Framebuffer"; -import { Graphics } from "../core/p5.Graphics"; import { RGB, RGBA } from '../color/creating_reading'; import { Element } from "../dom/p5.Element"; import { Image } from '../image/p5.Image'; @@ -450,76 +449,6 @@ class RendererGL extends Renderer3D { return { adjustedWidth, adjustedHeight }; } - //This is helper function to reset the context anytime the attributes - //are changed with setAttributes() - - _resetContext(options, callback) { - const w = this.width; - const h = this.height; - const defaultId = this.canvas.id; - const isPGraphics = this._pInst instanceof Graphics; - - // Preserve existing position and styles before recreation - const prevStyle = { - position: this.canvas.style.position, - top: this.canvas.style.top, - left: this.canvas.style.left, - }; - - if (isPGraphics) { - // Handle PGraphics: remove and recreate the canvas - const pg = this._pInst; - pg.canvas.parentNode.removeChild(pg.canvas); - pg.canvas = document.createElement("canvas"); - const node = pg._pInst._userNode || document.body; - node.appendChild(pg.canvas); - Element.call(pg, pg.canvas, pg._pInst); - // Restore previous width and height - pg.width = w; - pg.height = h; - } else { - // Handle main canvas: remove and recreate it - let c = this.canvas; - if (c) { - c.parentNode.removeChild(c); - } - c = document.createElement("canvas"); - c.id = defaultId; - // Attach the new canvas to the correct parent node - if (this._pInst._userNode) { - this._pInst._userNode.appendChild(c); - } else { - document.body.appendChild(c); - } - this._pInst.canvas = c; - this.canvas = c; - - // Restore the saved position - this.canvas.style.position = prevStyle.position; - this.canvas.style.top = prevStyle.top; - this.canvas.style.left = prevStyle.left; - } - - const renderer = new RendererGL( - this._pInst, - w, - h, - !isPGraphics, - this._pInst.canvas - ); - this._pInst._renderer = renderer; - - renderer._applyDefaults(); - - if (typeof callback === "function") { - //setTimeout with 0 forces the task to the back of the queue, this ensures that - //we finish switching out the renderer - setTimeout(() => { - callback.apply(window._renderer, options); - }, 0); - } - } - _resetBuffersBeforeDraw() { this.GL.clearStencil(0); this.GL.clear(this.GL.DEPTH_BUFFER_BIT | this.GL.STENCIL_BUFFER_BIT); @@ -2196,7 +2125,7 @@ function rendererGL(p5, fn) { } } - this._renderer._resetContext(); + this._renderer._resetContext(null, null, RendererGL); if (this._renderer.states.curCamera) { this._renderer.states.curCamera._renderer = this._renderer; diff --git a/src/webgpu/p5.RendererWebGPU.js b/src/webgpu/p5.RendererWebGPU.js index f85fd4607b..58c17f35aa 100644 --- a/src/webgpu/p5.RendererWebGPU.js +++ b/src/webgpu/p5.RendererWebGPU.js @@ -9,6 +9,8 @@ import * as constants from '../core/constants'; import { colorVertexShader, colorFragmentShader } from './shaders/color'; import { lineVertexShader, lineFragmentShader} from './shaders/line'; import { materialVertexShader, materialFragmentShader } from './shaders/material'; +import {Graphics} from "../core/p5.Graphics"; +import {Element} from "../dom/p5.Element"; const { lineDefs } = getStrokeDefs((n, v, t) => `const ${n}: ${t} = ${v};\n`); @@ -29,7 +31,25 @@ class RendererWebGPU extends Renderer3D { } async setupContext() { - this.adapter = await navigator.gpu?.requestAdapter(); + this._setAttributeDefaults(this._pInst); + await this._initContext(); + } + + _setAttributeDefaults(pInst) { + const defaults = { + forceFallbackAdapter: false, + powerPreference: 'high-performance', + }; + if (pInst._webgpuAttributes === null) { + pInst._webgpuAttributes = defaults; + } else { + pInst._webgpuAttributes = Object.assign(defaults, pInst._webgpuAttributes); + } + return; + } + + async _initContext() { + this.adapter = await navigator.gpu?.requestAdapter(this._webgpuAttributes); this.device = await this.adapter?.requestDevice({ // Todo: check support requiredFeatures: ['depth32float-stencil8'] @@ -1854,6 +1874,55 @@ function rendererWebGPU(p5, fn) { fn.ensureTexture = function(source) { return this._renderer.ensureTexture(source); } + + fn.setAttributes = async function (key, value) { + if (typeof this._webgpuAttributes === "undefined") { + console.log( + "You are trying to use setAttributes on a p5.Graphics object " + + "that does not use a WebGPU renderer." + ); + return; + } + let unchanged = true; + + if (typeof value !== "undefined") { + //first time modifying the attributes + if (this._webgpuAttributes === null) { + this._webgpuAttributes = {}; + } + if (this._webgpuAttributes[key] !== value) { + //changing value of previously altered attribute + this._webgpuAttributes[key] = value; + unchanged = false; + } + //setting all attributes with some change + } else if (key instanceof Object) { + if (this._webgpuAttributes !== key) { + this._webgpuAttributes = key; + unchanged = false; + } + } + //@todo_FES + if (!this._renderer.isP3D || unchanged) { + return; + } + + if (!this._setupDone) { + if (this._renderer.geometryBufferCache.numCached() > 0) { + p5._friendlyError( + "Sorry, Could not set the attributes, you need to call setAttributes() " + + "before calling the other drawing methods in setup()" + ); + return; + } + } + + await this._renderer._resetContext(null, null, RendererWebGPU); + + if (this._renderer.states.curCamera) { + this._renderer.states.curCamera._renderer = this._renderer; + } + } } export default rendererWebGPU; diff --git a/test/unit/visual/cases/webgpu.js b/test/unit/visual/cases/webgpu.js index 28382dda25..130dabf83b 100644 --- a/test/unit/visual/cases/webgpu.js +++ b/test/unit/visual/cases/webgpu.js @@ -11,6 +11,9 @@ visualSuite("WebGPU", function () { "The color shader runs successfully", async function (p5, screenshot) { await p5.createCanvas(50, 50, p5.WEBGPU); + await p5.setAttributes({ + forceFallbackAdapter: true + }); p5.background("white"); for (const [i, color] of ["red", "lime", "blue"].entries()) { p5.push(); @@ -29,6 +32,9 @@ visualSuite("WebGPU", function () { "The stroke shader runs successfully", async function (p5, screenshot) { await p5.createCanvas(50, 50, p5.WEBGPU); + await p5.setAttributes({ + forceFallbackAdapter: true + }); p5.background("white"); for (const [i, color] of ["red", "lime", "blue"].entries()) { p5.push(); @@ -47,6 +53,9 @@ visualSuite("WebGPU", function () { "The material shader runs successfully", async function (p5, screenshot) { await p5.createCanvas(50, 50, p5.WEBGPU); + await p5.setAttributes({ + forceFallbackAdapter: true + }); p5.background("white"); p5.ambientLight(50); p5.directionalLight(100, 100, 100, 0, 1, -1); @@ -68,6 +77,9 @@ visualSuite("WebGPU", function () { visualTest("Shader hooks can be used", async function (p5, screenshot) { await p5.createCanvas(50, 50, p5.WEBGPU); + await p5.setAttributes({ + forceFallbackAdapter: true + }); const myFill = p5.baseMaterialShader().modify({ "Vertex getWorldInputs": `(inputs: Vertex) { var result = inputs; @@ -96,6 +108,9 @@ visualSuite("WebGPU", function () { "Textures in the material shader work", async function (p5, screenshot) { await p5.createCanvas(50, 50, p5.WEBGPU); + await p5.setAttributes({ + forceFallbackAdapter: true + }); const tex = p5.createImage(50, 50); tex.loadPixels(); for (let x = 0; x < tex.width; x++) { @@ -121,6 +136,9 @@ visualSuite("WebGPU", function () { "Main canvas drawing after resize", async function (p5, screenshot) { await p5.createCanvas(50, 50, p5.WEBGPU); + await p5.setAttributes({ + forceFallbackAdapter: true + }); // Resize the canvas p5.resizeCanvas(30, 30); // Draw to the main canvas after resize @@ -138,7 +156,9 @@ visualSuite("WebGPU", function () { "Basic framebuffer draw to canvas", async function (p5, screenshot) { await p5.createCanvas(50, 50, p5.WEBGPU); - + await p5.setAttributes({ + forceFallbackAdapter: true + }); // Create a framebuffer const fbo = p5.createFramebuffer({ width: 25, height: 25 }); @@ -164,7 +184,9 @@ visualSuite("WebGPU", function () { "Framebuffer with different sizes", async function (p5, screenshot) { await p5.createCanvas(50, 50, p5.WEBGPU); - + await p5.setAttributes({ + forceFallbackAdapter: true + }); // Create two different sized framebuffers const fbo1 = p5.createFramebuffer({ width: 20, height: 20 }); const fbo2 = p5.createFramebuffer({ width: 15, height: 15 }); @@ -207,7 +229,9 @@ visualSuite("WebGPU", function () { visualTest("Auto-sized framebuffer", async function (p5, screenshot) { await p5.createCanvas(50, 50, p5.WEBGPU); - + await p5.setAttributes({ + forceFallbackAdapter: true + }); // Create auto-sized framebuffer (should match canvas size) const fbo = p5.createFramebuffer(); @@ -242,7 +266,9 @@ visualSuite("WebGPU", function () { "Auto-sized framebuffer after canvas resize", async function (p5, screenshot) { await p5.createCanvas(50, 50, p5.WEBGPU); - + await p5.setAttributes({ + forceFallbackAdapter: true + }); // Create auto-sized framebuffer const fbo = p5.createFramebuffer(); @@ -274,7 +300,9 @@ visualSuite("WebGPU", function () { "Fixed-size framebuffer after manual resize", async function (p5, screenshot) { await p5.createCanvas(50, 50, p5.WEBGPU); - + await p5.setAttributes({ + forceFallbackAdapter: true + }); // Create fixed-size framebuffer const fbo = p5.createFramebuffer({ width: 20, height: 20 }); diff --git a/test/unit/webgpu/p5.Framebuffer.js b/test/unit/webgpu/p5.Framebuffer.js index 452585b6c8..97cb8a13dd 100644 --- a/test/unit/webgpu/p5.Framebuffer.js +++ b/test/unit/webgpu/p5.Framebuffer.js @@ -15,6 +15,13 @@ suite('WebGPU p5.Framebuffer', function() { }); }); + beforeEach(async function() { + const renderer = await myp5.createCanvas(10, 10, 'webgpu'); + await myp5.setAttributes({ + forceFallbackAdapter: true + }); + }) + afterAll(function() { myp5.remove(); window.devicePixelRatio = prevPixelRatio; @@ -22,7 +29,6 @@ suite('WebGPU p5.Framebuffer', function() { suite('Creation and basic properties', function() { test('framebuffers can be created with WebGPU renderer', async function() { - await myp5.createCanvas(10, 10, myp5.WEBGPU); const fbo = myp5.createFramebuffer(); expect(fbo).to.be.an('object'); @@ -32,7 +38,6 @@ suite('WebGPU p5.Framebuffer', function() { }); test('framebuffers can be created with custom dimensions', async function() { - await myp5.createCanvas(10, 10, myp5.WEBGPU); const fbo = myp5.createFramebuffer({ width: 20, height: 30 }); expect(fbo.width).to.equal(20); @@ -41,7 +46,6 @@ suite('WebGPU p5.Framebuffer', function() { }); test('framebuffers have color texture', async function() { - await myp5.createCanvas(10, 10, myp5.WEBGPU); const fbo = myp5.createFramebuffer(); expect(fbo.color).to.be.an('object'); @@ -49,7 +53,6 @@ suite('WebGPU p5.Framebuffer', function() { }); test('framebuffers can specify different formats', async function() { - await myp5.createCanvas(10, 10, myp5.WEBGPU); const fbo = myp5.createFramebuffer({ format: 'float', channels: 'rgb' @@ -63,7 +66,6 @@ suite('WebGPU p5.Framebuffer', function() { suite('Auto-sizing behavior', function() { test('auto-sized framebuffers change size with canvas', async function() { - await myp5.createCanvas(10, 10, myp5.WEBGPU); myp5.pixelDensity(1); const fbo = myp5.createFramebuffer(); @@ -80,7 +82,6 @@ suite('WebGPU p5.Framebuffer', function() { }); test('manually-sized framebuffers do not change size with canvas', async function() { - await myp5.createCanvas(10, 10, myp5.WEBGPU); myp5.pixelDensity(3); const fbo = myp5.createFramebuffer({ width: 25, height: 30, density: 1 }); @@ -97,7 +98,6 @@ suite('WebGPU p5.Framebuffer', function() { }); test('manually-sized framebuffers can be made auto-sized', async function() { - await myp5.createCanvas(10, 10, myp5.WEBGPU); myp5.pixelDensity(1); const fbo = myp5.createFramebuffer({ width: 25, height: 30, density: 2 }); @@ -120,7 +120,6 @@ suite('WebGPU p5.Framebuffer', function() { suite('Manual resizing', function() { test('framebuffers can be manually resized', async function() { - await myp5.createCanvas(10, 10, myp5.WEBGPU); myp5.pixelDensity(1); const fbo = myp5.createFramebuffer(); @@ -135,7 +134,6 @@ suite('WebGPU p5.Framebuffer', function() { }); test('resizing affects pixel density', async function() { - await myp5.createCanvas(10, 10, myp5.WEBGPU); myp5.pixelDensity(1); const fbo = myp5.createFramebuffer(); @@ -152,7 +150,6 @@ suite('WebGPU p5.Framebuffer', function() { suite('Drawing functionality', function() { test('can draw to framebuffer with draw() method', async function() { - await myp5.createCanvas(10, 10, myp5.WEBGPU); const fbo = myp5.createFramebuffer(); myp5.background(0, 255, 0); @@ -178,7 +175,6 @@ suite('WebGPU p5.Framebuffer', function() { }); test('can use framebuffer as texture', async function() { - await myp5.createCanvas(10, 10, myp5.WEBGPU); const fbo = myp5.createFramebuffer(); fbo.draw(() => { @@ -195,7 +191,6 @@ suite('WebGPU p5.Framebuffer', function() { suite('Pixel access', function() { test('loadPixels returns a promise in WebGPU', async function() { - await myp5.createCanvas(10, 10, myp5.WEBGPU); const fbo = myp5.createFramebuffer(); fbo.draw(() => { @@ -212,7 +207,6 @@ suite('WebGPU p5.Framebuffer', function() { }); test('pixels property is set after loadPixels resolves', async function() { - await myp5.createCanvas(10, 10, myp5.WEBGPU); const fbo = myp5.createFramebuffer(); fbo.draw(() => { @@ -225,7 +219,6 @@ suite('WebGPU p5.Framebuffer', function() { }); test('get() returns a promise for single pixel in WebGPU', async function() { - await myp5.createCanvas(10, 10, myp5.WEBGPU); const fbo = myp5.createFramebuffer(); fbo.draw(() => { @@ -242,7 +235,6 @@ suite('WebGPU p5.Framebuffer', function() { }); test('get() returns a promise for region in WebGPU', async function() { - await myp5.createCanvas(10, 10, myp5.WEBGPU); const fbo = myp5.createFramebuffer(); fbo.draw(() => { From 695e9e60a1f4d58ff159d6eccb17ee70cf9654e7 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Sun, 14 Sep 2025 09:02:58 -0400 Subject: [PATCH 59/72] Try ignore-blocklist flag --- vitest.workspace.mjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/vitest.workspace.mjs b/vitest.workspace.mjs index 23055b6680..90b4173e2f 100644 --- a/vitest.workspace.mjs +++ b/vitest.workspace.mjs @@ -32,6 +32,7 @@ export default defineWorkspace([ './test/unit/spec.js', './test/unit/assets/**/*', './test/unit/visual/visualTest.js', + './test/unit/visual/cases/webgpu.js', ], testTimeout: 10000, globals: true, @@ -71,7 +72,8 @@ export default defineWorkspace([ name: 'unit-tests-firefox', root: './', include: [ - './test/unit/**/*.js', + './test/unit/visual/cases/webgpu.js', + // './test/unit/**/*.js', ], exclude: [ './test/unit/spec.js', @@ -96,6 +98,7 @@ export default defineWorkspace([ 'dom.webgpu.enabled': true, 'gfx.webgpu.force-enabled': true, 'dom.webgpu.testing.assert-on-warnings': false, + 'gfx.webgpu.ignore-blocklist': true, } } } : undefined From 48ce3209d3d1c22f67164e60924262f4a2d1f784 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Sun, 14 Sep 2025 09:34:32 -0400 Subject: [PATCH 60/72] Go back to ubuntu for now, try different swiftshader flags --- .github/workflows/ci-test.yml | 14 -------- vitest.workspace.mjs | 60 ++++------------------------------- 2 files changed, 7 insertions(+), 67 deletions(-) diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index 416d01777d..328d090e23 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -14,9 +14,6 @@ jobs: strategy: matrix: include: - - os: windows-latest - browser: firefox - test-workspace: unit-tests-firefox - os: ubuntu-latest browser: chrome test-workspace: unit-tests-chrome @@ -31,11 +28,6 @@ jobs: with: node-version: 20.x - - name: Verify Firefox (Windows) - if: matrix.os == 'windows-latest' && matrix.browser == 'firefox' - run: | - & "C:\Program Files\Mozilla Firefox\firefox.exe" --version - - name: Verify Chrome (Ubuntu) if: matrix.os == 'ubuntu-latest' && matrix.browser == 'chrome' run: | @@ -52,12 +44,6 @@ jobs: env: CI: true - - name: Build and test (Windows) - if: matrix.os == 'windows-latest' - run: npm test -- --project=${{ matrix.test-workspace }} - env: - CI: true - - name: Report test coverage run: bash <(curl -s https://codecov.io/bash) -f coverage/coverage-final.json env: diff --git a/vitest.workspace.mjs b/vitest.workspace.mjs index 90b4173e2f..bca96ad5a8 100644 --- a/vitest.workspace.mjs +++ b/vitest.workspace.mjs @@ -26,13 +26,14 @@ export default defineWorkspace([ name: 'unit-tests-chrome', root: './', include: [ - './test/unit/**/*.js', + // './test/unit/**/*.js', + './test/unit/visual/cases/webgpu.js', ], exclude: [ './test/unit/spec.js', './test/unit/assets/**/*', './test/unit/visual/visualTest.js', - './test/unit/visual/cases/webgpu.js', + // './test/unit/visual/cases/webgpu.js', ], testTimeout: 10000, globals: true, @@ -47,10 +48,11 @@ export default defineWorkspace([ args: [ '--no-sandbox', '--headless=new', - '--use-angle=vulkan', - '--enable-features=Vulkan', - '--disable-vulkan-surface', '--enable-unsafe-webgpu', + '--enable-features=Vulkan', + '--use-vulkan=swiftshader', + '--use-webgpu-adapter=swiftshader', + '--no-sandbox', ] } } : undefined @@ -58,52 +60,4 @@ export default defineWorkspace([ } } }, - { - plugins, - publicDir: './test', - bench: { - name: 'bench', - root: './', - include: [ - './test/bench/**/*.js' - ], - }, - test: { - name: 'unit-tests-firefox', - root: './', - include: [ - './test/unit/visual/cases/webgpu.js', - // './test/unit/**/*.js', - ], - exclude: [ - './test/unit/spec.js', - './test/unit/assets/**/*', - './test/unit/visual/visualTest.js', - ], - testTimeout: 10000, - globals: true, - browser: { - enabled: true, - name: 'firefox', - provider: 'webdriverio', - screenshotFailures: false, - providerOptions: { - capabilities: process.env.CI ? { - 'moz:firefoxOptions': { - args: [ - '--headless', - '--enable-webgpu', - ], - prefs: { - 'dom.webgpu.enabled': true, - 'gfx.webgpu.force-enabled': true, - 'dom.webgpu.testing.assert-on-warnings': false, - 'gfx.webgpu.ignore-blocklist': true, - } - } - } : undefined - } - } - } - } ]); \ No newline at end of file From 47535445966433bbcc36a0db56ca7a32c4dc83a7 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Sun, 14 Sep 2025 09:39:31 -0400 Subject: [PATCH 61/72] Different flag --- vitest.workspace.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vitest.workspace.mjs b/vitest.workspace.mjs index bca96ad5a8..6802ae3588 100644 --- a/vitest.workspace.mjs +++ b/vitest.workspace.mjs @@ -49,9 +49,9 @@ export default defineWorkspace([ '--no-sandbox', '--headless=new', '--enable-unsafe-webgpu', - '--enable-features=Vulkan', '--use-vulkan=swiftshader', '--use-webgpu-adapter=swiftshader', + '--use-angle=vulkan', '--no-sandbox', ] } From 9eb541d8ff2be5745a6be13d60d4655a1a8b3db7 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Sun, 14 Sep 2025 09:41:45 -0400 Subject: [PATCH 62/72] Check if the adapter is defined --- src/webgpu/p5.RendererWebGPU.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/webgpu/p5.RendererWebGPU.js b/src/webgpu/p5.RendererWebGPU.js index 58c17f35aa..daa7eeffc0 100644 --- a/src/webgpu/p5.RendererWebGPU.js +++ b/src/webgpu/p5.RendererWebGPU.js @@ -50,6 +50,11 @@ class RendererWebGPU extends Renderer3D { async _initContext() { this.adapter = await navigator.gpu?.requestAdapter(this._webgpuAttributes); + console.log('Adapter:'); + console.log(this.adapter); + if (this.adapter) { + console.log([...this.adapter.features]); + } this.device = await this.adapter?.requestDevice({ // Todo: check support requiredFeatures: ['depth32float-stencil8'] From 74c167243661e3e867c104d2717f42800386a556 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Sun, 14 Sep 2025 09:58:15 -0400 Subject: [PATCH 63/72] Try without setAttributes since the adapter seems to exist before that --- src/webgpu/p5.RendererWebGPU.js | 2 ++ test/unit/visual/cases/webgpu.js | 33 -------------------------------- 2 files changed, 2 insertions(+), 33 deletions(-) diff --git a/src/webgpu/p5.RendererWebGPU.js b/src/webgpu/p5.RendererWebGPU.js index daa7eeffc0..8d3f8c2e24 100644 --- a/src/webgpu/p5.RendererWebGPU.js +++ b/src/webgpu/p5.RendererWebGPU.js @@ -59,6 +59,8 @@ class RendererWebGPU extends Renderer3D { // Todo: check support requiredFeatures: ['depth32float-stencil8'] }); + console.log('Device:'); + console.log(this.device); if (!this.device) { throw new Error('Your browser does not support WebGPU.'); } diff --git a/test/unit/visual/cases/webgpu.js b/test/unit/visual/cases/webgpu.js index 130dabf83b..bffc88c219 100644 --- a/test/unit/visual/cases/webgpu.js +++ b/test/unit/visual/cases/webgpu.js @@ -11,9 +11,6 @@ visualSuite("WebGPU", function () { "The color shader runs successfully", async function (p5, screenshot) { await p5.createCanvas(50, 50, p5.WEBGPU); - await p5.setAttributes({ - forceFallbackAdapter: true - }); p5.background("white"); for (const [i, color] of ["red", "lime", "blue"].entries()) { p5.push(); @@ -32,9 +29,6 @@ visualSuite("WebGPU", function () { "The stroke shader runs successfully", async function (p5, screenshot) { await p5.createCanvas(50, 50, p5.WEBGPU); - await p5.setAttributes({ - forceFallbackAdapter: true - }); p5.background("white"); for (const [i, color] of ["red", "lime", "blue"].entries()) { p5.push(); @@ -53,9 +47,6 @@ visualSuite("WebGPU", function () { "The material shader runs successfully", async function (p5, screenshot) { await p5.createCanvas(50, 50, p5.WEBGPU); - await p5.setAttributes({ - forceFallbackAdapter: true - }); p5.background("white"); p5.ambientLight(50); p5.directionalLight(100, 100, 100, 0, 1, -1); @@ -77,9 +68,6 @@ visualSuite("WebGPU", function () { visualTest("Shader hooks can be used", async function (p5, screenshot) { await p5.createCanvas(50, 50, p5.WEBGPU); - await p5.setAttributes({ - forceFallbackAdapter: true - }); const myFill = p5.baseMaterialShader().modify({ "Vertex getWorldInputs": `(inputs: Vertex) { var result = inputs; @@ -108,9 +96,6 @@ visualSuite("WebGPU", function () { "Textures in the material shader work", async function (p5, screenshot) { await p5.createCanvas(50, 50, p5.WEBGPU); - await p5.setAttributes({ - forceFallbackAdapter: true - }); const tex = p5.createImage(50, 50); tex.loadPixels(); for (let x = 0; x < tex.width; x++) { @@ -136,9 +121,6 @@ visualSuite("WebGPU", function () { "Main canvas drawing after resize", async function (p5, screenshot) { await p5.createCanvas(50, 50, p5.WEBGPU); - await p5.setAttributes({ - forceFallbackAdapter: true - }); // Resize the canvas p5.resizeCanvas(30, 30); // Draw to the main canvas after resize @@ -156,9 +138,6 @@ visualSuite("WebGPU", function () { "Basic framebuffer draw to canvas", async function (p5, screenshot) { await p5.createCanvas(50, 50, p5.WEBGPU); - await p5.setAttributes({ - forceFallbackAdapter: true - }); // Create a framebuffer const fbo = p5.createFramebuffer({ width: 25, height: 25 }); @@ -184,9 +163,6 @@ visualSuite("WebGPU", function () { "Framebuffer with different sizes", async function (p5, screenshot) { await p5.createCanvas(50, 50, p5.WEBGPU); - await p5.setAttributes({ - forceFallbackAdapter: true - }); // Create two different sized framebuffers const fbo1 = p5.createFramebuffer({ width: 20, height: 20 }); const fbo2 = p5.createFramebuffer({ width: 15, height: 15 }); @@ -229,9 +205,6 @@ visualSuite("WebGPU", function () { visualTest("Auto-sized framebuffer", async function (p5, screenshot) { await p5.createCanvas(50, 50, p5.WEBGPU); - await p5.setAttributes({ - forceFallbackAdapter: true - }); // Create auto-sized framebuffer (should match canvas size) const fbo = p5.createFramebuffer(); @@ -266,9 +239,6 @@ visualSuite("WebGPU", function () { "Auto-sized framebuffer after canvas resize", async function (p5, screenshot) { await p5.createCanvas(50, 50, p5.WEBGPU); - await p5.setAttributes({ - forceFallbackAdapter: true - }); // Create auto-sized framebuffer const fbo = p5.createFramebuffer(); @@ -300,9 +270,6 @@ visualSuite("WebGPU", function () { "Fixed-size framebuffer after manual resize", async function (p5, screenshot) { await p5.createCanvas(50, 50, p5.WEBGPU); - await p5.setAttributes({ - forceFallbackAdapter: true - }); // Create fixed-size framebuffer const fbo = p5.createFramebuffer({ width: 20, height: 20 }); From 498bb836fd1c2b54a293009a66be78486eb82b10 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Sun, 14 Sep 2025 10:05:41 -0400 Subject: [PATCH 64/72] Try installing chrome with swiftshader --- .github/workflows/ci-test.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index 328d090e23..d1e98902a2 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -27,6 +27,13 @@ jobs: uses: actions/setup-node@v4 with: node-version: 20.x + + - name: Install Chrome with SwiftShader + run: | + sudo apt-get update + curl -sSL https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb -o chrome.deb + sudo apt-get install -y ./chrome.deb + ls -R /opt/google/chrome/swiftshader - name: Verify Chrome (Ubuntu) if: matrix.os == 'ubuntu-latest' && matrix.browser == 'chrome' From bc3501a10b98271888ffe0e68f344caf0bae61f4 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Sun, 14 Sep 2025 10:18:44 -0400 Subject: [PATCH 65/72] Revert "Try installing chrome with swiftshader" This reverts commit 498bb836fd1c2b54a293009a66be78486eb82b10. --- .github/workflows/ci-test.yml | 7 ------- 1 file changed, 7 deletions(-) diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index d1e98902a2..328d090e23 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -27,13 +27,6 @@ jobs: uses: actions/setup-node@v4 with: node-version: 20.x - - - name: Install Chrome with SwiftShader - run: | - sudo apt-get update - curl -sSL https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb -o chrome.deb - sudo apt-get install -y ./chrome.deb - ls -R /opt/google/chrome/swiftshader - name: Verify Chrome (Ubuntu) if: matrix.os == 'ubuntu-latest' && matrix.browser == 'chrome' From 88dec1b8b3a9bffda5b3e8dffdf274d74306fa35 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Sun, 14 Sep 2025 10:26:08 -0400 Subject: [PATCH 66/72] Try chrome on windows --- .github/workflows/ci-test.yml | 22 ++++++++++++---- vitest.workspace.mjs | 49 ++++++++++++++++++++++++++++++++++- 2 files changed, 65 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index 328d090e23..1f0c7f4ca9 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -14,9 +14,10 @@ jobs: strategy: matrix: include: - - os: ubuntu-latest + #- os: ubuntu-latest + # browser: chrome + - os: windows-latest browser: chrome - test-workspace: unit-tests-chrome runs-on: ${{ matrix.os }} @@ -32,18 +33,29 @@ jobs: if: matrix.os == 'ubuntu-latest' && matrix.browser == 'chrome' run: | google-chrome --version + + - name: Verify Chrome (Windows) + if: matrix.os == 'windows-latest' && matrix.browser == 'chrome' + run: | + & "C:\Program Files\Google\Chrome\Application\chrome.exe" --version - name: Get node modules run: npm ci env: CI: true - + - name: Build and test (Ubuntu) - if: matrix.os == 'ubuntu-latest' - run: npm test -- --project=${{ matrix.test-workspace }} + if: matrix.os == 'windows-latest' + run: npm test -- --project=unit-tests-webgpu env: CI: true + #- name: Build and test (Ubuntu) + # if: matrix.os == 'ubuntu-latest' + # run: npm test -- --project=unit-tests + # env: + # CI: true + - name: Report test coverage run: bash <(curl -s https://codecov.io/bash) -f coverage/coverage-final.json env: diff --git a/vitest.workspace.mjs b/vitest.workspace.mjs index 6802ae3588..d2d95bb049 100644 --- a/vitest.workspace.mjs +++ b/vitest.workspace.mjs @@ -23,7 +23,54 @@ export default defineWorkspace([ ], }, test: { - name: 'unit-tests-chrome', + name: 'unit-tests', + root: './', + include: [ + './test/unit/**/*.js', + ], + exclude: [ + './test/unit/spec.js', + './test/unit/assets/**/*', + './test/unit/visual/visualTest.js', + './test/unit/visual/cases/webgpu.js', + ], + testTimeout: 10000, + globals: true, + browser: { + enabled: true, + name: 'chrome', + provider: 'webdriverio', + screenshotFailures: false, + providerOptions: { + capabilities: process.env.CI ? { + 'goog:chromeOptions': { + args: [ + '--no-sandbox', + '--headless=new', + '--enable-unsafe-webgpu', + '--use-vulkan=swiftshader', + '--use-webgpu-adapter=swiftshader', + '--use-angle=vulkan', + '--no-sandbox', + ] + } + } : undefined + } + } + } + }, + { + plugins, + publicDir: './test', + bench: { + name: 'bench', + root: './', + include: [ + './test/bench/**/*.js' + ], + }, + test: { + name: 'unit-tests-webgpu', root: './', include: [ // './test/unit/**/*.js', From d5f584f4e0fe1ad5f7cc615f2b17dcd2fa745e54 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Sun, 14 Sep 2025 10:43:57 -0400 Subject: [PATCH 67/72] Don't run webgpu tests on CI for now --- .github/workflows/ci-test.yml | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index 1f0c7f4ca9..04dcd79306 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -14,11 +14,11 @@ jobs: strategy: matrix: include: - #- os: ubuntu-latest - # browser: chrome - - os: windows-latest + - os: ubuntu-latest browser: chrome - + # - os: windows-latest + # browser: chrome + runs-on: ${{ matrix.os }} steps: @@ -44,18 +44,18 @@ jobs: env: CI: true - - name: Build and test (Ubuntu) - if: matrix.os == 'windows-latest' - run: npm test -- --project=unit-tests-webgpu - env: - CI: true - #- name: Build and test (Ubuntu) - # if: matrix.os == 'ubuntu-latest' - # run: npm test -- --project=unit-tests + # if: matrix.os == 'windows-latest' + # run: npm test -- --project=unit-tests-webgpu # env: # CI: true - + + - name: Build and test (Ubuntu) + if: matrix.os == 'ubuntu-latest' + run: npm test -- --project=unit-tests + env: + CI: true + - name: Report test coverage run: bash <(curl -s https://codecov.io/bash) -f coverage/coverage-final.json env: From 3ea32edaaefffef9d0e8b04f7ce8fa89d7df927b Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Sun, 14 Sep 2025 10:48:51 -0400 Subject: [PATCH 68/72] Exclude other webgpu tests --- vitest.workspace.mjs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/vitest.workspace.mjs b/vitest.workspace.mjs index d2d95bb049..13dca58dbe 100644 --- a/vitest.workspace.mjs +++ b/vitest.workspace.mjs @@ -33,6 +33,7 @@ export default defineWorkspace([ './test/unit/assets/**/*', './test/unit/visual/visualTest.js', './test/unit/visual/cases/webgpu.js', + './test/unit/webgpu/*.js', ], testTimeout: 10000, globals: true, @@ -75,6 +76,7 @@ export default defineWorkspace([ include: [ // './test/unit/**/*.js', './test/unit/visual/cases/webgpu.js', + './test/unit/webgpu/*.js', ], exclude: [ './test/unit/spec.js', From 724b41a11f6504d071c883036480f938efd64117 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Sun, 14 Sep 2025 11:06:51 -0400 Subject: [PATCH 69/72] Move setAttributes implementation to renderer --- src/webgl/p5.RendererGL.js | 94 +++++++++++++------------- src/webgpu/p5.RendererWebGPU.js | 105 +++++++++++++++-------------- test/unit/webgpu/p5.Framebuffer.js | 5 +- vitest.workspace.mjs | 5 +- 4 files changed, 107 insertions(+), 102 deletions(-) diff --git a/src/webgl/p5.RendererGL.js b/src/webgl/p5.RendererGL.js index ab15ea3d81..d22668cc10 100644 --- a/src/webgl/p5.RendererGL.js +++ b/src/webgl/p5.RendererGL.js @@ -382,6 +382,54 @@ class RendererGL extends Renderer3D { return; } + _setAttributes(key, value) { + if (typeof this._pInst._glAttributes === "undefined") { + console.log( + "You are trying to use setAttributes on a p5.Graphics object " + + "that does not use a WEBGL renderer." + ); + return; + } + let unchanged = true; + if (typeof value !== "undefined") { + //first time modifying the attributes + if (this._pInst._glAttributes === null) { + this._pInst._glAttributes = {}; + } + if (this._pInst._glAttributes[key] !== value) { + //changing value of previously altered attribute + this._pInst._glAttributes[key] = value; + unchanged = false; + } + //setting all attributes with some change + } else if (key instanceof Object) { + if (this._pInst._glAttributes !== key) { + this._pInst._glAttributes = key; + unchanged = false; + } + } + //@todo_FES + if (!this.isP3D || unchanged) { + return; + } + + if (!this._pInst._setupDone) { + if (this.geometryBufferCache.numCached() > 0) { + p5._friendlyError( + "Sorry, Could not set the attributes, you need to call setAttributes() " + + "before calling the other drawing methods in setup()" + ); + return; + } + } + + this._resetContext(null, null, RendererGL); + + if (this.states.curCamera) { + this.states.curCamera._renderer = this._renderer; + } + } + _initContext() { if (this._pInst._glAttributes?.version !== 1) { // Unless WebGL1 is explicitly asked for, try to create a WebGL2 context @@ -2085,51 +2133,7 @@ function rendererGL(p5, fn) { * @param {Object} obj object with key-value pairs */ fn.setAttributes = function (key, value) { - if (typeof this._glAttributes === "undefined") { - console.log( - "You are trying to use setAttributes on a p5.Graphics object " + - "that does not use a WEBGL renderer." - ); - return; - } - let unchanged = true; - if (typeof value !== "undefined") { - //first time modifying the attributes - if (this._glAttributes === null) { - this._glAttributes = {}; - } - if (this._glAttributes[key] !== value) { - //changing value of previously altered attribute - this._glAttributes[key] = value; - unchanged = false; - } - //setting all attributes with some change - } else if (key instanceof Object) { - if (this._glAttributes !== key) { - this._glAttributes = key; - unchanged = false; - } - } - //@todo_FES - if (!this._renderer.isP3D || unchanged) { - return; - } - - if (!this._setupDone) { - if (this._renderer.geometryBufferCache.numCached() > 0) { - p5._friendlyError( - "Sorry, Could not set the attributes, you need to call setAttributes() " + - "before calling the other drawing methods in setup()" - ); - return; - } - } - - this._renderer._resetContext(null, null, RendererGL); - - if (this._renderer.states.curCamera) { - this._renderer.states.curCamera._renderer = this._renderer; - } + return this._renderer._setAttributes(key, value); }; /** diff --git a/src/webgpu/p5.RendererWebGPU.js b/src/webgpu/p5.RendererWebGPU.js index 8d3f8c2e24..2bcf39949d 100644 --- a/src/webgpu/p5.RendererWebGPU.js +++ b/src/webgpu/p5.RendererWebGPU.js @@ -50,8 +50,8 @@ class RendererWebGPU extends Renderer3D { async _initContext() { this.adapter = await navigator.gpu?.requestAdapter(this._webgpuAttributes); - console.log('Adapter:'); - console.log(this.adapter); + // console.log('Adapter:'); + // console.log(this.adapter); if (this.adapter) { console.log([...this.adapter.features]); } @@ -59,8 +59,8 @@ class RendererWebGPU extends Renderer3D { // Todo: check support requiredFeatures: ['depth32float-stencil8'] }); - console.log('Device:'); - console.log(this.device); + // console.log('Device:'); + // console.log(this.device); if (!this.device) { throw new Error('Your browser does not support WebGPU.'); } @@ -79,6 +79,55 @@ class RendererWebGPU extends Renderer3D { this._update(); } + async _setAttributes(key, value) { + if (typeof this._pInst._webgpuAttributes === "undefined") { + console.log( + "You are trying to use setAttributes on a p5.Graphics object " + + "that does not use a WebGPU renderer." + ); + return; + } + let unchanged = true; + + if (typeof value !== "undefined") { + //first time modifying the attributes + if (this._pInst._webgpuAttributes === null) { + this._pInst._webgpuAttributes = {}; + } + if (this._pInst._webgpuAttributes[key] !== value) { + //changing value of previously altered attribute + this._webgpuAttributes[key] = value; + unchanged = false; + } + //setting all attributes with some change + } else if (key instanceof Object) { + if (this._pInst._webgpuAttributes !== key) { + this._pInst._webgpuAttributes = key; + unchanged = false; + } + } + //@todo_FES + if (!this.isP3D || unchanged) { + return; + } + + if (!this._pInst._setupDone) { + if (this.geometryBufferCache.numCached() > 0) { + p5._friendlyError( + "Sorry, Could not set the attributes, you need to call setAttributes() " + + "before calling the other drawing methods in setup()" + ); + return; + } + } + + await this._resetContext(null, null, RendererWebGPU); + + if (this.states.curCamera) { + this.states.curCamera._renderer = this._renderer; + } + } + _updateSize() { if (this.depthTexture && this.depthTexture.destroy) { this.depthTexture.destroy(); @@ -1882,53 +1931,9 @@ function rendererWebGPU(p5, fn) { return this._renderer.ensureTexture(source); } + // TODO: move this and the duplicate in the WebGL renderer to another file fn.setAttributes = async function (key, value) { - if (typeof this._webgpuAttributes === "undefined") { - console.log( - "You are trying to use setAttributes on a p5.Graphics object " + - "that does not use a WebGPU renderer." - ); - return; - } - let unchanged = true; - - if (typeof value !== "undefined") { - //first time modifying the attributes - if (this._webgpuAttributes === null) { - this._webgpuAttributes = {}; - } - if (this._webgpuAttributes[key] !== value) { - //changing value of previously altered attribute - this._webgpuAttributes[key] = value; - unchanged = false; - } - //setting all attributes with some change - } else if (key instanceof Object) { - if (this._webgpuAttributes !== key) { - this._webgpuAttributes = key; - unchanged = false; - } - } - //@todo_FES - if (!this._renderer.isP3D || unchanged) { - return; - } - - if (!this._setupDone) { - if (this._renderer.geometryBufferCache.numCached() > 0) { - p5._friendlyError( - "Sorry, Could not set the attributes, you need to call setAttributes() " + - "before calling the other drawing methods in setup()" - ); - return; - } - } - - await this._renderer._resetContext(null, null, RendererWebGPU); - - if (this._renderer.states.curCamera) { - this._renderer.states.curCamera._renderer = this._renderer; - } + return this._renderer._setAttributes(key, value); } } diff --git a/test/unit/webgpu/p5.Framebuffer.js b/test/unit/webgpu/p5.Framebuffer.js index 97cb8a13dd..ccbadbc7a0 100644 --- a/test/unit/webgpu/p5.Framebuffer.js +++ b/test/unit/webgpu/p5.Framebuffer.js @@ -16,10 +16,7 @@ suite('WebGPU p5.Framebuffer', function() { }); beforeEach(async function() { - const renderer = await myp5.createCanvas(10, 10, 'webgpu'); - await myp5.setAttributes({ - forceFallbackAdapter: true - }); + await myp5.createCanvas(10, 10, 'webgpu'); }) afterAll(function() { diff --git a/vitest.workspace.mjs b/vitest.workspace.mjs index 13dca58dbe..a8da776a67 100644 --- a/vitest.workspace.mjs +++ b/vitest.workspace.mjs @@ -1,6 +1,5 @@ import { defineWorkspace } from 'vitest/config'; import vitePluginString from 'vite-plugin-string'; -console.log(`CI: ${process.env.CI}`) const plugins = [ vitePluginString({ @@ -35,7 +34,7 @@ export default defineWorkspace([ './test/unit/visual/cases/webgpu.js', './test/unit/webgpu/*.js', ], - testTimeout: 10000, + testTimeout: 1000, globals: true, browser: { enabled: true, @@ -84,7 +83,7 @@ export default defineWorkspace([ './test/unit/visual/visualTest.js', // './test/unit/visual/cases/webgpu.js', ], - testTimeout: 10000, + testTimeout: 1000, globals: true, browser: { enabled: true, From 254d1a66f545badcc53529c492731eaf2c0be069 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Sun, 16 Nov 2025 10:14:54 -0500 Subject: [PATCH 70/72] Add whole-module addon for webgpu, prevent duplicate addon registers --- src/core/main.js | 7 +++++++ src/webgl/index.js | 34 +++++++++++++++--------------- src/webgpu/index.js | 37 +++++++++++++++++++++++++++++++++ src/webgpu/p5.RendererWebGPU.js | 4 ++++ 4 files changed, 65 insertions(+), 17 deletions(-) create mode 100644 src/webgpu/index.js diff --git a/src/core/main.js b/src/core/main.js index fe8076ca9a..8e2d292f1d 100644 --- a/src/core/main.js +++ b/src/core/main.js @@ -172,9 +172,16 @@ class p5 { return this._renderer.drawingContext; } + static _registeredAddons = new Set(); static registerAddon(addon) { const lifecycles = {}; + // Don't re-register an addon. This allows addons + // to register dependency addons without worrying about + // them getting double-added. + if (p5._registeredAddons.has(addon)) return; + p5._registeredAddons.add(addon); + addon(p5, p5.prototype, lifecycles); const validLifecycles = Object.keys(p5.lifecycleHooks); diff --git a/src/webgl/index.js b/src/webgl/index.js index 52292100e8..2f84a9ec19 100644 --- a/src/webgl/index.js +++ b/src/webgl/index.js @@ -17,21 +17,21 @@ import rendererGL from './p5.RendererGL'; import strands from '../strands/p5.strands'; export default function(p5){ - rendererGL(p5, p5.prototype); - primitives3D(p5, p5.prototype); - interaction(p5, p5.prototype); - light(p5, p5.prototype); - loading(p5, p5.prototype); - material(p5, p5.prototype); - text(p5, p5.prototype); - renderBuffer(p5, p5.prototype); - quat(p5, p5.prototype); - matrix(p5, p5.prototype); - geometry(p5, p5.prototype); - camera(p5, p5.prototype); - framebuffer(p5, p5.prototype); - dataArray(p5, p5.prototype); - shader(p5, p5.prototype); - texture(p5, p5.prototype); - strands(p5, p5.prototype); + p5.registerAddon(rendererGL); + p5.registerAddon(primitives3D); + p5.registerAddon(interaction); + p5.registerAddon(light); + p5.registerAddon(loading); + p5.registerAddon(material); + p5.registerAddon(text); + p5.registerAddon(renderBuffer); + p5.registerAddon(quat); + p5.registerAddon(matrix); + p5.registerAddon(geometry); + p5.registerAddon(camera); + p5.registerAddon(framebuffer); + p5.registerAddon(dataArray); + p5.registerAddon(shader); + p5.registerAddon(texture); + p5.registerAddon(strands); } diff --git a/src/webgpu/index.js b/src/webgpu/index.js new file mode 100644 index 0000000000..015a140eab --- /dev/null +++ b/src/webgpu/index.js @@ -0,0 +1,37 @@ +import primitives3D from '../webgl/3d_primitives'; +import interaction from '../webgl/interaction'; +import light from '../webgl/light'; +import loading from '../webgl/loading'; +import material from '../webgl/material'; +import text from '../webgl/text'; +import renderBuffer from '../webgl/p5.RenderBuffer'; +import quat from '../webgl/p5.Quat'; +import matrix from '../math/p5.Matrix'; +import geometry from '../webgl/p5.Geometry'; +import framebuffer from '../webgl/p5.Framebuffer'; +import dataArray from '../webgl/p5.DataArray'; +import shader from '../webgl/p5.Shader'; +import camera from '../webgl/p5.Camera'; +import texture from '../webgl/p5.Texture'; +import rendererGL from '../webgl/p5.RendererGL'; +import strands from '../strands/p5.strands'; + +export default function(p5){ + p5.registerAddon(rendererGL); + p5.registerAddon(primitives3D); + p5.registerAddon(interaction); + p5.registerAddon(light); + p5.registerAddon(loading); + p5.registerAddon(material); + p5.registerAddon(text); + p5.registerAddon(renderBuffer); + p5.registerAddon(quat); + p5.registerAddon(matrix); + p5.registerAddon(geometry); + p5.registerAddon(camera); + p5.registerAddon(framebuffer); + p5.registerAddon(dataArray); + p5.registerAddon(shader); + p5.registerAddon(texture); + p5.registerAddon(strands); +} diff --git a/src/webgpu/p5.RendererWebGPU.js b/src/webgpu/p5.RendererWebGPU.js index 2bcf39949d..128bbf431b 100644 --- a/src/webgpu/p5.RendererWebGPU.js +++ b/src/webgpu/p5.RendererWebGPU.js @@ -1939,3 +1939,7 @@ function rendererWebGPU(p5, fn) { export default rendererWebGPU; export { RendererWebGPU }; + +if (typeof p5 !== "undefined") { + rendererWebGPU(p5, p5.prototype); +} From cb221da8b1e6169d81f29fc4b7fcbf283409a9ad Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Sun, 16 Nov 2025 10:30:15 -0500 Subject: [PATCH 71/72] Add webgpu to package.json --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index cb63d3caae..f0b148ae1d 100644 --- a/package.json +++ b/package.json @@ -94,6 +94,7 @@ "./math": "./dist/math/index.js", "./utilities": "./dist/utilities/index.js", "./webgl": "./dist/webgl/index.js", + "./webgpu": "./dist/webgpu/index.js", "./type": "./dist/type/index.js" }, "files": [ From ef28cbc7dbd9990c9e855cebb4adee8a6d6ecc7e Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Sun, 16 Nov 2025 12:12:08 -0500 Subject: [PATCH 72/72] Get font rendering working on WebGPU --- preview/index.html | 20 ++ src/core/p5.Renderer2D.js | 103 ++++++ src/core/p5.Renderer3D.js | 108 ++++++- src/type/p5.Font.js | 3 + src/type/textCore.js | 205 +----------- src/webgl/3d_primitives.js | 2 +- src/webgl/p5.Camera.js | 8 +- src/webgl/p5.RendererGL.js | 74 +---- src/webgl/shaders/font.vert | 2 - src/webgl/shaders/light_texture.frag | 13 +- src/webgl/shaders/phong.frag | 7 +- src/webgl/shaders/point.frag | 29 -- src/webgl/shaders/point.vert | 19 -- src/webgl/shaders/webgl2Compatibility.glsl | 6 +- src/webgl/text.js | 22 +- src/webgpu/p5.RendererWebGPU.js | 156 +++++++-- src/webgpu/shaders/font.js | 283 +++++++++++++++++ test/unit/visual/cases/webgpu.js | 300 ++++++++++++++++++ .../Main canvas drawing after resize/000.png | Bin 225 -> 274 bytes .../000.png | Bin 167 -> 306 bytes .../Auto-sized framebuffer/000.png | Bin 396 -> 593 bytes .../Basic framebuffer draw to canvas/000.png | Bin 521 -> 656 bytes .../000.png | Bin 291 -> 546 bytes .../Framebuffer with different sizes/000.png | Bin 402 -> 491 bytes .../Shaders/Shader hooks can be used/000.png | Bin 474 -> 667 bytes .../000.png | Bin 275 -> 975 bytes .../000.png | Bin 427 -> 539 bytes .../000.png | Bin 1707 -> 1813 bytes .../000.png | Bin 510 -> 735 bytes .../000.png | Bin 0 -> 2325 bytes .../001.png | Bin 0 -> 2316 bytes .../002.png | Bin 0 -> 2318 bytes .../003.png | Bin 0 -> 2321 bytes .../004.png | Bin 0 -> 2306 bytes .../005.png | Bin 0 -> 2305 bytes .../006.png | Bin 0 -> 2324 bytes .../007.png | Bin 0 -> 2301 bytes .../008.png | Bin 0 -> 2303 bytes .../metadata.json | 3 + .../000.png | Bin 0 -> 3583 bytes .../001.png | Bin 0 -> 3603 bytes .../002.png | Bin 0 -> 3584 bytes .../003.png | Bin 0 -> 3632 bytes .../004.png | Bin 0 -> 3648 bytes .../005.png | Bin 0 -> 3625 bytes .../006.png | Bin 0 -> 3559 bytes .../007.png | Bin 0 -> 3636 bytes .../008.png | Bin 0 -> 3566 bytes .../metadata.json | 3 + .../000.png | Bin 0 -> 3592 bytes .../001.png | Bin 0 -> 3589 bytes .../002.png | Bin 0 -> 3571 bytes .../003.png | Bin 0 -> 3526 bytes .../004.png | Bin 0 -> 3527 bytes .../005.png | Bin 0 -> 3523 bytes .../006.png | Bin 0 -> 3530 bytes .../007.png | Bin 0 -> 3538 bytes .../008.png | Bin 0 -> 3532 bytes .../metadata.json | 3 + .../all alignments with single line/000.png | Bin 0 -> 6315 bytes .../all alignments with single line/001.png | Bin 0 -> 7284 bytes .../all alignments with single line/002.png | Bin 0 -> 5242 bytes .../all alignments with single line/003.png | Bin 0 -> 6337 bytes .../all alignments with single line/004.png | Bin 0 -> 7209 bytes .../all alignments with single line/005.png | Bin 0 -> 5271 bytes .../all alignments with single line/006.png | Bin 0 -> 6323 bytes .../all alignments with single line/007.png | Bin 0 -> 7307 bytes .../all alignments with single line/008.png | Bin 0 -> 5229 bytes .../metadata.json | 3 + .../all alignments with single word/000.png | Bin 0 -> 6527 bytes .../all alignments with single word/001.png | Bin 0 -> 6996 bytes .../all alignments with single word/002.png | Bin 0 -> 4957 bytes .../all alignments with single word/003.png | Bin 0 -> 6522 bytes .../all alignments with single word/004.png | Bin 0 -> 6968 bytes .../all alignments with single word/005.png | Bin 0 -> 4971 bytes .../all alignments with single word/006.png | Bin 0 -> 6524 bytes .../all alignments with single word/007.png | Bin 0 -> 6973 bytes .../all alignments with single word/008.png | Bin 0 -> 4976 bytes .../metadata.json | 3 + .../with a font file in WebGPU/000.png | Bin 0 -> 2276 bytes .../with a font file in WebGPU/metadata.json | 3 + .../000.png | Bin 0 -> 2844 bytes .../001.png | Bin 0 -> 2868 bytes .../002.png | Bin 0 -> 2872 bytes .../003.png | Bin 0 -> 2883 bytes .../004.png | Bin 0 -> 2893 bytes .../metadata.json | 3 + 87 files changed, 983 insertions(+), 398 deletions(-) delete mode 100644 src/webgl/shaders/point.frag delete mode 100644 src/webgl/shaders/point.vert create mode 100644 src/webgpu/shaders/font.js create mode 100644 test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-line manual text/000.png create mode 100644 test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-line manual text/001.png create mode 100644 test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-line manual text/002.png create mode 100644 test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-line manual text/003.png create mode 100644 test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-line manual text/004.png create mode 100644 test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-line manual text/005.png create mode 100644 test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-line manual text/006.png create mode 100644 test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-line manual text/007.png create mode 100644 test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-line manual text/008.png create mode 100644 test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-line manual text/metadata.json create mode 100644 test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-lines and wrap char/000.png create mode 100644 test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-lines and wrap char/001.png create mode 100644 test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-lines and wrap char/002.png create mode 100644 test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-lines and wrap char/003.png create mode 100644 test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-lines and wrap char/004.png create mode 100644 test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-lines and wrap char/005.png create mode 100644 test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-lines and wrap char/006.png create mode 100644 test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-lines and wrap char/007.png create mode 100644 test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-lines and wrap char/008.png create mode 100644 test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-lines and wrap char/metadata.json create mode 100644 test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-lines and wrap word/000.png create mode 100644 test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-lines and wrap word/001.png create mode 100644 test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-lines and wrap word/002.png create mode 100644 test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-lines and wrap word/003.png create mode 100644 test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-lines and wrap word/004.png create mode 100644 test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-lines and wrap word/005.png create mode 100644 test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-lines and wrap word/006.png create mode 100644 test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-lines and wrap word/007.png create mode 100644 test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-lines and wrap word/008.png create mode 100644 test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-lines and wrap word/metadata.json create mode 100644 test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with single line/000.png create mode 100644 test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with single line/001.png create mode 100644 test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with single line/002.png create mode 100644 test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with single line/003.png create mode 100644 test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with single line/004.png create mode 100644 test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with single line/005.png create mode 100644 test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with single line/006.png create mode 100644 test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with single line/007.png create mode 100644 test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with single line/008.png create mode 100644 test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with single line/metadata.json create mode 100644 test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with single word/000.png create mode 100644 test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with single word/001.png create mode 100644 test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with single word/002.png create mode 100644 test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with single word/003.png create mode 100644 test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with single word/004.png create mode 100644 test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with single word/005.png create mode 100644 test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with single word/006.png create mode 100644 test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with single word/007.png create mode 100644 test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with single word/008.png create mode 100644 test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with single word/metadata.json create mode 100644 test/unit/visual/screenshots/WebGPU/Typography/textFont/with a font file in WebGPU/000.png create mode 100644 test/unit/visual/screenshots/WebGPU/Typography/textFont/with a font file in WebGPU/metadata.json create mode 100644 test/unit/visual/screenshots/WebGPU/Typography/textWeight/can control variable fonts from files in WebGPU/000.png create mode 100644 test/unit/visual/screenshots/WebGPU/Typography/textWeight/can control variable fonts from files in WebGPU/001.png create mode 100644 test/unit/visual/screenshots/WebGPU/Typography/textWeight/can control variable fonts from files in WebGPU/002.png create mode 100644 test/unit/visual/screenshots/WebGPU/Typography/textWeight/can control variable fonts from files in WebGPU/003.png create mode 100644 test/unit/visual/screenshots/WebGPU/Typography/textWeight/can control variable fonts from files in WebGPU/004.png create mode 100644 test/unit/visual/screenshots/WebGPU/Typography/textWeight/can control variable fonts from files in WebGPU/metadata.json diff --git a/preview/index.html b/preview/index.html index 4092992316..ba1f0bd282 100644 --- a/preview/index.html +++ b/preview/index.html @@ -27,9 +27,13 @@ let sh; let ssh; let tex; + let font; p.setup = async function () { await p.createCanvas(400, 400, p.WEBGPU); + font = await p.loadFont( + 'font/PlayfairDisplay.ttf' + ); fbo = p.createFramebuffer(); tex = p.createImage(100, 100); @@ -72,6 +76,22 @@ }; p.draw = function () { + p.clear(); + p.orbitControl(); + p.push(); + p.textAlign(p.CENTER, p.CENTER); + p.textFont(font); + p.textSize(85) + p.fill('red') + p.noStroke() + p.rect(0, 0, 100, 100); + p.fill(0); + p.push() + p.rotate(p.millis() * 0.001) + p.text('Hello!', 0, 0); + p.pop() + p.pop(); + return; p.orbitControl(); const t = p.millis() * 0.002; p.background(200); diff --git a/src/core/p5.Renderer2D.js b/src/core/p5.Renderer2D.js index b9a7e12d00..132049d530 100644 --- a/src/core/p5.Renderer2D.js +++ b/src/core/p5.Renderer2D.js @@ -9,6 +9,7 @@ import { RGBHDR } from '../color/creating_reading'; import FilterRenderer2D from '../image/filterRenderer2D'; import { Matrix } from '../math/p5.Matrix'; import { PrimitiveToPath2DConverter } from '../shape/custom_shapes'; +import { DefaultFill, textCoreConstants } from '../type/textCore'; const styleEmpty = 'rgba(0,0,0,0)'; @@ -1054,6 +1055,108 @@ class Renderer2D extends Renderer { super.pop(style); } + + // Text support methods + textCanvas() { + return this.canvas; + } + + textDrawingContext() { + return this.drawingContext; + } + + _renderText(text, x, y, maxY, minY) { + let states = this.states; + let context = this.textDrawingContext(); + + if (y < minY || y >= maxY) { + return; // don't render lines beyond minY/maxY + } + + this.push(); + + // no stroke unless specified by user + if (states.strokeColor && states.strokeSet) { + context.strokeText(text, x, y); + } + + if (!this._clipping && states.fillColor) { + + // if fill hasn't been set by user, use default text fill + if (!states.fillSet) { + this._setFill(DefaultFill); + } + context.fillText(text, x, y); + } + + this.pop(); + } + + /* + Position the lines of text based on their textAlign/textBaseline properties + */ + _positionLines(x, y, width, height, lines) { + let { textLeading, textAlign } = this.states; + let adjustedX, lineData = new Array(lines.length); + let adjustedW = typeof width === 'undefined' ? 0 : width; + let adjustedH = typeof height === 'undefined' ? 0 : height; + + for (let i = 0; i < lines.length; i++) { + switch (textAlign) { + case textCoreConstants.START: + throw new Error('textBounds: START not yet supported for textAlign'); // default to LEFT + case constants.LEFT: + adjustedX = x; + break; + case constants.CENTER: + adjustedX = x + adjustedW / 2; + break; + case constants.RIGHT: + adjustedX = x + adjustedW; + break; + case textCoreConstants.END: + throw new Error('textBounds: END not yet supported for textAlign'); + } + lineData[i] = { text: lines[i], x: adjustedX, y: y + i * textLeading }; + } + + return this._yAlignOffset(lineData, adjustedH); + } + + /* + Get the y-offset for text given the height, leading, line-count and textBaseline property + */ + _yAlignOffset(dataArr, height) { + if (typeof height === 'undefined') { + throw Error('_yAlignOffset: height is required'); + } + + let { textLeading, textBaseline } = this.states; + let yOff = 0, numLines = dataArr.length; + let ydiff = height - (textLeading * (numLines - 1)); + + switch (textBaseline) { // drawingContext ? + case constants.TOP: + break; // ?? + case constants.BASELINE: + break; + case textCoreConstants._CTX_MIDDLE: + yOff = ydiff / 2; + break; + case constants.BOTTOM: + yOff = ydiff; + break; + case textCoreConstants.IDEOGRAPHIC: + console.warn('textBounds: IDEOGRAPHIC not yet supported for textBaseline'); // FES? + break; + case textCoreConstants.HANGING: + console.warn('textBounds: HANGING not yet supported for textBaseline'); // FES? + break; + } + + dataArr.forEach(ele => ele.y += yOff); + return dataArr; + } } function renderer2D(p5, fn){ diff --git a/src/core/p5.Renderer3D.js b/src/core/p5.Renderer3D.js index 1c828d7a2e..0f93a7a910 100644 --- a/src/core/p5.Renderer3D.js +++ b/src/core/p5.Renderer3D.js @@ -13,6 +13,7 @@ import { Color } from "../color/p5.Color"; import { Element } from "../dom/p5.Element"; import { Framebuffer } from "../webgl/p5.Framebuffer"; import { DataArray } from "../webgl/p5.DataArray"; +import { textCoreConstants } from "../type/textCore"; import { RenderBuffer } from "../webgl/p5.RenderBuffer"; import { Image } from "../image/p5.Image"; import { Texture } from "../webgl/p5.Texture"; @@ -1643,17 +1644,6 @@ export class Renderer3D extends Renderer { } } - _setPointUniforms(pointShader) { - // set the uniform values - pointShader.setUniform("uMaterialColor", this.states.curStrokeColor); - // @todo is there an instance where this isn't stroke weight? - // should be they be same var? - pointShader.setUniform( - "uPointSize", - this.states.strokeWeight * this._pixelDensity - ); - } - /** * @private * Note: DO NOT CALL THIS while in the middle of binding another texture, @@ -1731,4 +1721,100 @@ export class Renderer3D extends Renderer { _vToNArray(arr) { return arr.flatMap((item) => [item.x, item.y, item.z]); } + + /////////////////////////////// + //// TEXT SUPPORT METHODS + ////////////////////////////// + + textCanvas() { + if (!this._textCanvas) { + this._textCanvas = document.createElement('canvas'); + this._textCanvas.width = 1; + this._textCanvas.height = 1; + this._textCanvas.style.display = 'none'; + // Has to be added to the DOM for measureText to work properly! + this.canvas.parentElement.insertBefore(this._textCanvas, this.canvas); + } + return this._textCanvas; + } + + textDrawingContext() { + if (!this._textDrawingContext) { + const textCanvas = this.textCanvas(); + this._textDrawingContext = textCanvas.getContext('2d'); + } + return this._textDrawingContext; + } + + _positionLines(x, y, width, height, lines) { + let { textLeading, textAlign } = this.states; + const widths = lines.map(line => this._fontWidthSingle(line)); + let adjustedX, lineData = new Array(lines.length); + let adjustedW = typeof width === 'undefined' ? Math.max(0, ...widths) : width; + let adjustedH = typeof height === 'undefined' ? 0 : height; + + for (let i = 0; i < lines.length; i++) { + switch (textAlign) { + case textCoreConstants.START: + throw new Error('textBounds: START not yet supported for textAlign'); // default to LEFT + case constants.LEFT: + adjustedX = x; + break; + case constants.CENTER: + adjustedX = x + + (adjustedW - widths[i]) / 2 - + adjustedW / 2 + + (width || 0) / 2; + break; + case constants.RIGHT: + adjustedX = x + adjustedW - widths[i] - adjustedW + (width || 0); + break; + case textCoreConstants.END: + throw new Error('textBounds: END not yet supported for textAlign'); + default: + adjustedX = x; + break; + } + lineData[i] = { text: lines[i], x: adjustedX, y: y + i * textLeading }; + } + + return this._yAlignOffset(lineData, adjustedH); + } + + _yAlignOffset(dataArr, height) { + if (typeof height === 'undefined') { + throw Error('_yAlignOffset: height is required'); + } + + let { textLeading, textBaseline, textSize, textFont } = this.states; + let yOff = 0, numLines = dataArr.length; + let totalHeight = textSize * numLines + + ((textLeading - textSize) * (numLines - 1)); + switch (textBaseline) { // drawingContext ? + case constants.TOP: + yOff = textSize; + break; + case constants.BASELINE: + break; + case textCoreConstants._CTX_MIDDLE: + yOff = -totalHeight / 2 + textSize + (height || 0) / 2; + break; + case constants.BOTTOM: + yOff = -(totalHeight - textSize) + (height || 0); + break; + default: + console.warn(`${textBaseline} is not supported in WebGL mode.`); // FES? + break; + } + yOff += this.states.textFont.font?._verticalAlign(textSize) || 0; + dataArr.forEach(ele => ele.y += yOff); + return dataArr; + } + + remove() { + if (this._textCanvas) { + this._textCanvas.parentElement.removeChild(this._textCanvas); + } + super.remove(); + } } diff --git a/src/type/p5.Font.js b/src/type/p5.Font.js index 26fbdd4996..bb5fdeada3 100644 --- a/src/type/p5.Font.js +++ b/src/type/p5.Font.js @@ -1064,6 +1064,9 @@ async function create(pInst, name, path, descriptors, rawFont) { // ensure the font is ready to be rendered await document.fonts.ready; + // Await loading of the font via CSS in case it also loads other resources + await document.fonts.load(`1em "${name}"`); + // return a new p5.Font return new Font(pInst, face, name, path, rawFont); } diff --git a/src/type/textCore.js b/src/type/textCore.js index cd55559b12..4ba10dc5d2 100644 --- a/src/type/textCore.js +++ b/src/type/textCore.js @@ -5,6 +5,8 @@ import { Renderer } from '../core/p5.Renderer'; +export const DefaultFill = '#000000'; + export const textCoreConstants = { IDEOGRAPHIC: 'ideographic', RIGHT_TO_LEFT: 'rtl', @@ -19,7 +21,6 @@ export const textCoreConstants = { function textCore(p5, fn) { const LeadingScale = 1.275; - const DefaultFill = '#000000'; const LinebreakRe = /\r?\n/g; const CommaDelimRe = /,\s+/; const QuotedRe = /^".*"$/; @@ -2525,208 +2526,6 @@ function textCore(p5, fn) { return this._pInst; }; - - if (p5.Renderer2D) { - p5.Renderer2D.prototype.textCanvas = function () { - return this.canvas; - }; - p5.Renderer2D.prototype.textDrawingContext = function () { - return this.drawingContext; - }; - - p5.Renderer2D.prototype._renderText = function (text, x, y, maxY, minY) { - let states = this.states; - let context = this.textDrawingContext(); - - if (y < minY || y >= maxY) { - return; // don't render lines beyond minY/maxY - } - - this.push(); - - // no stroke unless specified by user - if (states.strokeColor && states.strokeSet) { - context.strokeText(text, x, y); - } - - if (!this._clipping && states.fillColor) { - - // if fill hasn't been set by user, use default text fill - if (!states.fillSet) { - this._setFill(DefaultFill); - } - context.fillText(text, x, y); - } - - this.pop(); - }; - - /* - Position the lines of text based on their textAlign/textBaseline properties - */ - p5.Renderer2D.prototype._positionLines = function ( - x, y, - width, height, - lines - ) { - - let { textLeading, textAlign } = this.states; - let adjustedX, lineData = new Array(lines.length); - let adjustedW = typeof width === 'undefined' ? 0 : width; - let adjustedH = typeof height === 'undefined' ? 0 : height; - - for (let i = 0; i < lines.length; i++) { - switch (textAlign) { - case textCoreConstants.START: - throw new Error('textBounds: START not yet supported for textAlign'); // default to LEFT - case fn.LEFT: - adjustedX = x; - break; - case fn.CENTER: - adjustedX = x + adjustedW / 2; - break; - case fn.RIGHT: - adjustedX = x + adjustedW; - break; - case textCoreConstants.END: - throw new Error('textBounds: END not yet supported for textAlign'); - } - lineData[i] = { text: lines[i], x: adjustedX, y: y + i * textLeading }; - } - - return this._yAlignOffset(lineData, adjustedH); - }; - - /* - Get the y-offset for text given the height, leading, line-count and textBaseline property - */ - p5.Renderer2D.prototype._yAlignOffset = function (dataArr, height) { - - if (typeof height === 'undefined') { - throw Error('_yAlignOffset: height is required'); - } - - let { textLeading, textBaseline } = this.states; - let yOff = 0, numLines = dataArr.length; - let ydiff = height - (textLeading * (numLines - 1)); - switch (textBaseline) { // drawingContext ? - case fn.TOP: - break; // ?? - case fn.BASELINE: - break; - case textCoreConstants._CTX_MIDDLE: - yOff = ydiff / 2; - break; - case fn.BOTTOM: - yOff = ydiff; - break; - case textCoreConstants.IDEOGRAPHIC: - console.warn('textBounds: IDEOGRAPHIC not yet supported for textBaseline'); // FES? - break; - case textCoreConstants.HANGING: - console.warn('textBounds: HANGING not yet supported for textBaseline'); // FES? - break; - } - dataArr.forEach(ele => ele.y += yOff); - return dataArr; - }; - } - - if (p5.RendererGL) { - p5.RendererGL.prototype.textCanvas = function() { - if (!this._textCanvas) { - this._textCanvas = document.createElement('canvas'); - this._textCanvas.width = 1; - this._textCanvas.height = 1; - this._textCanvas.style.display = 'none'; - // Has to be added to the DOM for measureText to work properly! - this.canvas.parentElement.insertBefore(this._textCanvas, this.canvas); - } - return this._textCanvas; - }; - p5.RendererGL.prototype.textDrawingContext = function() { - if (!this._textDrawingContext) { - const textCanvas = this.textCanvas(); - this._textDrawingContext = textCanvas.getContext('2d'); - } - return this._textDrawingContext; - }; - const oldRemove = p5.RendererGL.prototype.remove; - p5.RendererGL.prototype.remove = function() { - if (this._textCanvas) { - this._textCanvas.parentElement.removeChild(this._textCanvas); - } - oldRemove.call(this); - }; - - p5.RendererGL.prototype._positionLines = function ( - x, y, - width, height, - lines - ) { - - let { textLeading, textAlign } = this.states; - const widths = lines.map(line => this._fontWidthSingle(line)); - let adjustedX, lineData = new Array(lines.length); - let adjustedW = typeof width === 'undefined' ? Math.max(0, ...widths) : width; - let adjustedH = typeof height === 'undefined' ? 0 : height; - - for (let i = 0; i < lines.length; i++) { - switch (textAlign) { - case textCoreConstants.START: - throw new Error('textBounds: START not yet supported for textAlign'); // default to LEFT - case fn.LEFT: - adjustedX = x; - break; - case fn.CENTER: - adjustedX = x + - (adjustedW - widths[i]) / 2 - - adjustedW / 2 + - (width || 0) / 2; - break; - case fn.RIGHT: - adjustedX = x + adjustedW - widths[i] - adjustedW + (width || 0); - break; - case textCoreConstants.END: - throw new Error('textBounds: END not yet supported for textAlign'); - } - lineData[i] = { text: lines[i], x: adjustedX, y: y + i * textLeading }; - } - - return this._yAlignOffset(lineData, adjustedH); - }; - - p5.RendererGL.prototype._yAlignOffset = function (dataArr, height) { - - if (typeof height === 'undefined') { - throw Error('_yAlignOffset: height is required'); - } - - let { textLeading, textBaseline, textSize, textFont } = this.states; - let yOff = 0, numLines = dataArr.length; - let totalHeight = textSize * numLines + - ((textLeading - textSize) * (numLines - 1)); - switch (textBaseline) { // drawingContext ? - case fn.TOP: - yOff = textSize; - break; - case fn.BASELINE: - break; - case textCoreConstants._CTX_MIDDLE: - yOff = -totalHeight / 2 + textSize + (height || 0) / 2; - break; - case fn.BOTTOM: - yOff = -(totalHeight - textSize) + (height || 0); - break; - default: - console.warn(`${textBaseline} is not supported in WebGL mode.`); // FES? - break; - } - yOff += this.states.textFont.font?._verticalAlign(textSize) || 0; // Does this function exist? - dataArr.forEach(ele => ele.y += yOff); - return dataArr; - }; - } } export default textCore; diff --git a/src/webgl/3d_primitives.js b/src/webgl/3d_primitives.js index 7fa69a7351..6ef5721e7b 100644 --- a/src/webgl/3d_primitives.js +++ b/src/webgl/3d_primitives.js @@ -1874,7 +1874,7 @@ function primitives3D(p5, fn){ if (typeof args[4] === 'undefined') { // Use the retained mode for drawing rectangle, // if args for rounding rectangle is not provided by user. - const perPixelLighting = this._pInst._glAttributes?.perPixelLighting; + const perPixelLighting = this._pInst._glAttributes?.perPixelLighting ?? true; const detailX = args[4] || (perPixelLighting ? 1 : 24); const detailY = args[5] || (perPixelLighting ? 1 : 16); const gid = `rect|${detailX}|${detailY}`; diff --git a/src/webgl/p5.Camera.js b/src/webgl/p5.Camera.js index 300d2f1d47..24f5d5fd79 100644 --- a/src/webgl/p5.Camera.js +++ b/src/webgl/p5.Camera.js @@ -238,8 +238,8 @@ class Camera { let A, B; if (range[0] === 0) { // WebGPU clip space, z in [0, 1] - A = far * nf; - B = far * near * nf; + A = far / (near - far); + B = (far * near) / (near - far); } else { // WebGL clip space, z in [-1, 1] A = (far + near) * nf; @@ -1792,8 +1792,8 @@ class Camera { this.defaultCenterX = 0; this.defaultCenterY = 0; this.defaultCenterZ = 0; - this.defaultCameraNear = this.defaultEyeZ * 0.1; - this.defaultCameraFar = this.defaultEyeZ * 10; + this.defaultCameraNear = this.defaultEyeZ * this._renderer.defaultNearScale(); + this.defaultCameraFar = this.defaultEyeZ * this._renderer.defaultFarScale(); } //detect if user didn't set the camera diff --git a/src/webgl/p5.RendererGL.js b/src/webgl/p5.RendererGL.js index 77c8aa57de..07181bcf61 100644 --- a/src/webgl/p5.RendererGL.js +++ b/src/webgl/p5.RendererGL.js @@ -31,8 +31,6 @@ import fontVert from "./shaders/font.vert"; import fontFrag from "./shaders/font.frag"; import lineVert from "./shaders/line.vert"; import lineFrag from "./shaders/line.frag"; -import pointVert from "./shaders/point.vert"; -import pointFrag from "./shaders/point.frag"; import imageLightVert from "./shaders/imageLight.vert"; import imageLightDiffusedFrag from "./shaders/imageLightDiffused.frag"; import imageLightSpecularFrag from "./shaders/imageLightSpecular.frag"; @@ -63,8 +61,6 @@ const defaultShaders = { fontFrag, lineVert: lineDefs + lineVert, lineFrag: lineDefs + lineFrag, - pointVert, - pointFrag, imageLightVert, imageLightDiffusedFrag, imageLightSpecularFrag, @@ -143,31 +139,6 @@ class RendererGL extends Renderer3D { // Rendering ////////////////////////////////////////////// - /*_drawPoints(vertices, vertexBuffer) { - const gl = this.GL; - const pointShader = this._getPointShader(); - pointShader.bindShader(); - this._setGlobalUniforms(pointShader); - this._setPointUniforms(pointShader); - pointShader.bindTextures(); - - this._bindBuffer( - vertexBuffer, - gl.ARRAY_BUFFER, - this._vToNArray(vertices), - Float32Array, - gl.STATIC_DRAW - ); - - pointShader.enableAttrib(pointShader.attributes.aPosition, 3); - - this._applyColorBlend(this.states.curStrokeColor); - - gl.drawArrays(gl.Points, 0, vertices.length); - - pointShader.unbindShader(); - }*/ - /** * @private sets blending in gl context to curBlendMode * @param {Number[]} color [description] @@ -302,6 +273,9 @@ class RendererGL extends Renderer3D { ); } } + } else if (this._curShader.shaderType === 'text') { + // Text rendering uses a fixed quad geometry with 6 indices + gl.drawElements(gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, 0); } else if (glBuffers.indexBuffer) { this._bindBuffer(glBuffers.indexBuffer, gl.ELEMENT_ARRAY_BUFFER); @@ -457,10 +431,9 @@ class RendererGL extends Renderer3D { gl.enable(gl.DEPTH_TEST); gl.depthFunc(gl.LEQUAL); gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight); - // Make sure all images are loaded into the canvas premultiplied so that - // they match the way we render colors. This will make framebuffer textures - // be encoded the same way as textures from everything else. - gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true); + // Make sure all images are loaded into the canvas non-premultiplied so that + // they can be handled consistently in shaders. + gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false); this._viewport = this.drawingContext.getParameter( this.drawingContext.VIEWPORT ); @@ -618,6 +591,12 @@ class RendererGL extends Renderer3D { zClipRange() { return [-1, 1]; } + defaultNearScale() { + return 0.1; + } + defaultFarScale() { + return 10; + } viewport(w, h) { this._viewport = [0, 0, w, h]; @@ -835,34 +814,6 @@ class RendererGL extends Renderer3D { return this._defaultColorShader; } - _getPointShader() { - if (!this._defaultPointShader) { - this._defaultPointShader = new Shader( - this, - this._webGL2CompatibilityPrefix("vert", "mediump") + - defaultShaders.pointVert, - this._webGL2CompatibilityPrefix("frag", "mediump") + - defaultShaders.pointFrag, - { - vertex: { - "void beforeVertex": "() {}", - "vec3 getLocalPosition": "(vec3 position) { return position; }", - "vec3 getWorldPosition": "(vec3 position) { return position; }", - "float getPointSize": "(float size) { return size; }", - "void afterVertex": "() {}", - }, - fragment: { - "void beforeFragment": "() {}", - "vec4 getFinalColor": "(vec4 color) { return color; }", - "bool shouldDiscard": "(bool outside) { return outside; }", - "void afterFragment": "() {}", - }, - } - ); - } - return this._defaultPointShader; - } - _getLineShader() { if (!this._defaultLineShader) { this._defaultLineShader = new Shader( @@ -1341,7 +1292,6 @@ class RendererGL extends Renderer3D { hasTransparency || this.states.userFillShader || this.states.userStrokeShader || - this.states.userPointShader || isTexture || this.states.curBlendMode !== constants.BLEND || colors[colors.length - 1] < 1.0 || diff --git a/src/webgl/shaders/font.vert b/src/webgl/shaders/font.vert index ce8b84ab18..6d893e8826 100644 --- a/src/webgl/shaders/font.vert +++ b/src/webgl/shaders/font.vert @@ -7,7 +7,6 @@ uniform vec4 uGlyphRect; uniform float uGlyphOffset; OUT vec2 vTexCoord; -OUT float w; void main() { vec4 positionVec4 = vec4(aPosition, 1.0); @@ -40,5 +39,4 @@ void main() { gl_Position = uProjectionMatrix * uModelViewMatrix * positionVec4; vTexCoord = aTexCoord + textureOffset; - w = gl_Position.w; } diff --git a/src/webgl/shaders/light_texture.frag b/src/webgl/shaders/light_texture.frag index e02083b97b..f61fc39cca 100644 --- a/src/webgl/shaders/light_texture.frag +++ b/src/webgl/shaders/light_texture.frag @@ -14,13 +14,12 @@ void main(void) { } else { vec4 baseColor = isTexture - // Textures come in with premultiplied alpha. To apply tint and still have - // premultiplied alpha output, we need to multiply the RGB channels by the - // tint RGB, and all channels by the tint alpha. - ? TEXTURE(uSampler, vVertTexCoord) * vec4(uTint.rgb/255., 1.) * (uTint.a/255.) - // Colors come in with unmultiplied alpha, so we need to multiply the RGB - // channels by alpha to convert it to premultiplied alpha. - : vec4(vColor.rgb * vColor.a, vColor.a); + // Textures come in with non-premultiplied alpha. Apply tint. + ? TEXTURE(uSampler, vVertTexCoord) * (uTint/255.) + // Colors come in with non-premultiplied alpha. + : vColor; + // Convert to premultiplied alpha for consistent output + baseColor.rgb *= baseColor.a; OUT_COLOR = vec4(baseColor.rgb * vDiffuseColor + vSpecularColor, baseColor.a); } } diff --git a/src/webgl/shaders/phong.frag b/src/webgl/shaders/phong.frag index 78cfb76163..5711a01e6d 100644 --- a/src/webgl/shaders/phong.frag +++ b/src/webgl/shaders/phong.frag @@ -47,13 +47,8 @@ void main(void) { inputs.texCoord = vTexCoord; inputs.ambientLight = uAmbientColor; inputs.color = isTexture - ? TEXTURE(uSampler, vTexCoord) * (vec4(uTint.rgb/255., 1.) * uTint.a/255.) + ? TEXTURE(uSampler, vTexCoord) * (uTint/255.) : vColor; - if (isTexture && inputs.color.a > 0.0) { - // Textures come in with premultiplied alpha. Temporarily unpremultiply it - // so hooks users don't have to think about premultiplied alpha. - inputs.color.rgb /= inputs.color.a; - } inputs.shininess = uShininess; inputs.metalness = uMetallic; inputs.ambientMaterial = uHasSetAmbient ? uAmbientMatColor.rgb : inputs.color.rgb; diff --git a/src/webgl/shaders/point.frag b/src/webgl/shaders/point.frag deleted file mode 100644 index d87cbf0c61..0000000000 --- a/src/webgl/shaders/point.frag +++ /dev/null @@ -1,29 +0,0 @@ -precision mediump int; -uniform vec4 uMaterialColor; -IN float vStrokeWeight; - -void main(){ - HOOK_beforeFragment(); - float mask = 0.0; - - // make a circular mask using the gl_PointCoord (goes from 0 - 1 on a point) - // might be able to get a nicer edge on big strokeweights with smoothstep but slightly less performant - - mask = step(0.98, length(gl_PointCoord * 2.0 - 1.0)); - - // if strokeWeight is 1 or less lets just draw a square - // this prevents weird artifacting from carving circles when our points are really small - // if strokeWeight is larger than 1, we just use it as is - - mask = mix(0.0, mask, clamp(floor(vStrokeWeight - 0.5),0.0,1.0)); - - // throw away the borders of the mask - // otherwise we get weird alpha blending issues - - if(HOOK_shouldDiscard(mask > 0.98)){ - discard; - } - - OUT_COLOR = HOOK_getFinalColor(vec4(uMaterialColor.rgb, 1.) * uMaterialColor.a); - HOOK_afterFragment(); -} diff --git a/src/webgl/shaders/point.vert b/src/webgl/shaders/point.vert deleted file mode 100644 index 6eeb741a64..0000000000 --- a/src/webgl/shaders/point.vert +++ /dev/null @@ -1,19 +0,0 @@ -IN vec3 aPosition; -uniform float uPointSize; -OUT float vStrokeWeight; -uniform mat4 uModelViewMatrix; -uniform mat4 uProjectionMatrix; - -void main() { - HOOK_beforeVertex(); - vec4 viewModelPosition = vec4(HOOK_getWorldPosition( - (uModelViewMatrix * vec4(HOOK_getLocalPosition(aPosition), 1.0)).xyz - ), 1.); - gl_Position = uProjectionMatrix * viewModelPosition; - - float pointSize = HOOK_getPointSize(uPointSize); - - gl_PointSize = pointSize; - vStrokeWeight = pointSize; - HOOK_afterVertex(); -} diff --git a/src/webgl/shaders/webgl2Compatibility.glsl b/src/webgl/shaders/webgl2Compatibility.glsl index 8c9dbddec6..15bfe7ed24 100644 --- a/src/webgl/shaders/webgl2Compatibility.glsl +++ b/src/webgl/shaders/webgl2Compatibility.glsl @@ -26,9 +26,5 @@ out vec4 outColor; #endif #ifdef FRAGMENT_SHADER -vec4 getTexture(in sampler2D content, vec2 coord) { - vec4 color = TEXTURE(content, coord); - if (color.a > 0.) color.rgb /= color.a; - return color; -} +#define getTexture TEXTURE #endif diff --git a/src/webgl/text.js b/src/webgl/text.js index b5a9842345..c8db34fc87 100644 --- a/src/webgl/text.js +++ b/src/webgl/text.js @@ -25,17 +25,6 @@ function text(p5, fn) { } }; - // Text/Typography (see src/type/textCore.js) - /* - Renderer3D.prototype.textWidth = function(s) { - if (this._isOpenType()) { - return this.states.textFont.font._textWidth(s, this.states.textSize); - } - - return 0; // TODO: error - }; - */ - // rendering constants // the number of rows/columns dividing each glyph @@ -729,7 +718,6 @@ function text(p5, fn) { this.scale(scale, scale, 1); // initialize the font shader - const gl = this.GL; const initializeShader = !this._defaultFontShader; const sh = this._getFontShader(); sh.init(); @@ -745,7 +733,7 @@ function text(p5, fn) { const curFillColor = this.states.fillSet ? this.states.curFillColor - : [0, 0, 0, 255]; + : [0, 0, 0, 1]; this._setGlobalUniforms(sh); this._applyColorBlend(curFillColor); @@ -775,14 +763,9 @@ function text(p5, fn) { for (const buff of this.buffers.text) { buff._prepareBuffer(g, sh); } - this._bindBuffer( - this.geometryBufferCache.cache.glyph.indexBuffer, - gl.ELEMENT_ARRAY_BUFFER - ); // this will have to do for now... sh.setUniform('uMaterialColor', curFillColor); - gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false); this.glyphDataCache = this.glyphDataCache || new Set(); @@ -834,7 +817,7 @@ function text(p5, fn) { sh.bindTextures(); // afterwards, only textures need updating // draw it - gl.drawElements(gl.TRIANGLES, 6, this.GL.UNSIGNED_SHORT, 0); + this._drawBuffers(g, { mode: constants.TRIANGLES, count: 1 }); } } } finally { @@ -843,7 +826,6 @@ function text(p5, fn) { this.states.setValue('strokeColor', doStroke); this.states.setValue('drawMode', drawMode); - gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true); this.pop(); } diff --git a/src/webgpu/p5.RendererWebGPU.js b/src/webgpu/p5.RendererWebGPU.js index 128bbf431b..2332a1d8f9 100644 --- a/src/webgpu/p5.RendererWebGPU.js +++ b/src/webgpu/p5.RendererWebGPU.js @@ -9,6 +9,7 @@ import * as constants from '../core/constants'; import { colorVertexShader, colorFragmentShader } from './shaders/color'; import { lineVertexShader, lineFragmentShader} from './shaders/line'; import { materialVertexShader, materialFragmentShader } from './shaders/material'; +import { fontVertexShader, fontFragmentShader } from './shaders/font'; import {Graphics} from "../core/p5.Graphics"; import {Element} from "../dom/p5.Element"; @@ -50,17 +51,10 @@ class RendererWebGPU extends Renderer3D { async _initContext() { this.adapter = await navigator.gpu?.requestAdapter(this._webgpuAttributes); - // console.log('Adapter:'); - // console.log(this.adapter); - if (this.adapter) { - console.log([...this.adapter.features]); - } this.device = await this.adapter?.requestDevice({ // Todo: check support requiredFeatures: ['depth32float-stencil8'] }); - // console.log('Device:'); - // console.log(this.device); if (!this.device) { throw new Error('Your browser does not support WebGPU.'); } @@ -71,6 +65,7 @@ class RendererWebGPU extends Renderer3D { device: this.device, format: this.presentationFormat, usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC, + alphaMode: 'premultiplied', }); // TODO disablable stencil @@ -203,6 +198,47 @@ class RendererWebGPU extends Renderer3D { this.queue.submit([commandEncoder.finish()]); } + /** + * Resets all depth information so that nothing previously drawn will + * occlude anything subsequently drawn. + */ + clearDepth(depth = 1) { + const commandEncoder = this.device.createCommandEncoder(); + + // Use framebuffer texture if active, otherwise use canvas texture + const activeFramebuffer = this.activeFramebuffer(); + + // Use framebuffer depth texture if active, otherwise use canvas depth texture + const depthTexture = activeFramebuffer ? + (activeFramebuffer.aaDepthTexture || activeFramebuffer.depthTexture) : + this.depthTexture; + const depthTextureView = depthTexture?.createView(); + + if (!depthTextureView) { + // No depth buffer to clear + return; + } + + const depthAttachment = { + view: depthTextureView, + depthClearValue: depth, + depthLoadOp: 'clear', + depthStoreOp: 'store', + stencilLoadOp: 'load', + stencilStoreOp: 'store', + }; + + const renderPassDescriptor = { + colorAttachments: [], // No color attachments, we're only clearing depth + depthStencilAttachment: depthAttachment, + }; + + const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor); + passEncoder.end(); + + this.queue.submit([commandEncoder.finish()]); + } + _prepareBuffer(renderBuffer, geometry, shader) { const attr = shader.attributes[renderBuffer.attr]; if (!attr) return; @@ -319,12 +355,18 @@ class RendererWebGPU extends Renderer3D { this._getWebGPUDepthFormat(activeFramebuffer) : this.depthFormat; + const drawTarget = this.drawTarget(); + const clipping = this._clipping; + const clipApplied = drawTarget._isClipApplied; + return { topology: mode === constants.TRIANGLE_STRIP ? 'triangle-strip' : 'triangle-list', blendMode: this.states.curBlendMode, sampleCount, format, depthFormat, + clipping, + clipApplied, } } @@ -335,8 +377,8 @@ class RendererWebGPU extends Renderer3D { shader.fragModule = device.createShaderModule({ code: shader.fragSrc() }); shader._pipelineCache = new Map(); - shader.getPipeline = ({ topology, blendMode, sampleCount, format, depthFormat }) => { - const key = `${topology}_${blendMode}_${sampleCount}_${format}_${depthFormat}`; + shader.getPipeline = ({ topology, blendMode, sampleCount, format, depthFormat, clipping, clipApplied }) => { + const key = `${topology}_${blendMode}_${sampleCount}_${format}_${depthFormat}_${clipping}_${clipApplied}`; if (!shader._pipelineCache.has(key)) { const pipeline = device.createRenderPipeline({ layout: shader._pipelineLayout, @@ -358,21 +400,21 @@ class RendererWebGPU extends Renderer3D { depthStencil: { format: depthFormat, depthWriteEnabled: true, - depthCompare: 'less', + depthCompare: 'less-equal', stencilFront: { - compare: 'always', + compare: clipping ? 'always' : (clipApplied ? 'equal' : 'always'), failOp: 'keep', depthFailOp: 'keep', - passOp: 'keep', + passOp: clipping ? 'replace' : 'keep', }, stencilBack: { - compare: 'always', + compare: clipping ? 'always' : (clipApplied ? 'equal' : 'always'), failOp: 'keep', depthFailOp: 'keep', - passOp: 'keep', + passOp: clipping ? 'replace' : 'keep', }, - stencilReadMask: 0xFFFFFFFF, // TODO - stencilWriteMask: 0xFFFFFFFF, + stencilReadMask: clipApplied ? 0xFFFFFFFF : 0x00000000, + stencilWriteMask: clipping ? 0xFFFFFFFF : 0x00000000, stencilLoadOp: "load", stencilStoreOp: "store", }, @@ -675,6 +717,12 @@ class RendererWebGPU extends Renderer3D { zClipRange() { return [0, 1]; } + defaultNearScale() { + return 0.01; + } + defaultFarScale() { + return 100; + } _resetBuffersBeforeDraw() { const commandEncoder = this.device.createCommandEncoder(); @@ -753,11 +801,9 @@ class RendererWebGPU extends Renderer3D { passEncoder.setPipeline(currentShader.getPipeline(this._shaderOptions({ mode }))); // Bind vertex buffers for (const buffer of this._getVertexBuffers(currentShader)) { - passEncoder.setVertexBuffer( - currentShader.attributes[buffer.attr].location, - buffers[buffer.dst], - 0 - ); + const location = currentShader.attributes[buffer.attr].location; + const gpuBuffer = buffers[buffer.dst]; + passEncoder.setVertexBuffer(location, gpuBuffer, 0); } // Bind uniforms this._packUniforms(this._curShader); @@ -810,6 +856,13 @@ class RendererWebGPU extends Renderer3D { } else { passEncoder.draw(geometry.vertices.length, count, 0, 0); } + } else if (currentShader.shaderType === "text") { + if (!buffers.indexBuffer) { + throw new Error("Text geometry must have an index buffer"); + } + const indexFormat = buffers.indexFormat || "uint16"; + passEncoder.setIndexBuffer(buffers.indexBuffer, indexFormat); + passEncoder.drawIndexed(geometry.faces.length * 3, count, 0, 0, 0); } if (buffers.lineVerticesBuffer && currentShader.shaderType === "stroke") { @@ -837,11 +890,34 @@ class RendererWebGPU extends Renderer3D { for (const name in shader.uniforms) { const uniform = shader.uniforms[name]; if (uniform.isSampler) continue; - if (uniform.type === 'u32') { - shader._uniformDataView.setUint32(uniform.offset, uniform._cachedData, true); + + if (uniform.baseType === 'u32') { + if (uniform.size === 4) { + // Single u32 + shader._uniformDataView.setUint32(uniform.offset, uniform._cachedData, true); + } else { + // Vector of u32s + const data = uniform._cachedData; + for (let i = 0; i < data.length; i++) { + shader._uniformDataView.setUint32(uniform.offset + i * 4, data[i], true); + } + } + } else if (uniform.baseType === 'i32') { + if (uniform.size === 4) { + // Single i32 + shader._uniformDataView.setInt32(uniform.offset, uniform._cachedData, true); + } else { + // Vector of i32s + const data = uniform._cachedData; + for (let i = 0; i < data.length; i++) { + shader._uniformDataView.setInt32(uniform.offset + i * 4, data[i], true); + } + } } else if (uniform.size === 4) { + // Single float value shader._uniformData.set([uniform._cachedData], uniform.offset / 4); } else { + // Float array (including vec2, vec3, vec4, mat4x4) shader._uniformData.set(uniform._cachedData, uniform.offset / 4); } } @@ -866,13 +942,21 @@ class RendererWebGPU extends Renderer3D { const baseAlignAndSize = (type) => { if (['f32', 'i32', 'u32', 'bool'].includes(type)) { - return { align: 4, size: 4, items: 1 }; + return { align: 4, size: 4, items: 1, baseType: type }; } if (/^vec[2-4](|f)$/.test(type)) { const n = parseInt(type.match(/^vec([2-4])/)[1]); const size = 4 * n; const align = n === 2 ? 8 : 16; - return { align, size, items: n }; + return { align, size, items: n, baseType: 'f32' }; + } + if (/^vec[2-4]<(i32|u32)>$/.test(type)) { + const n = parseInt(type.match(/^vec([2-4])/)[1]); + const match = type.match(/^vec[2-4]<(i32|u32)>$/); + const baseType = match[1]; // 'i32' or 'u32' + const size = 4 * n; + const align = n === 2 ? 8 : 16; + return { align, size, items: n, baseType }; } if (/^mat[2-4](?:x[2-4])?(|f)$/.test(type)) { if (type[4] === 'x' && type[3] !== type[5]) { @@ -892,7 +976,7 @@ class RendererWebGPU extends Renderer3D { 0 ] : undefined; - return { align, size, pack, items: dim * dim }; + return { align, size, pack, items: dim * dim, baseType: 'f32' }; } if (/^array<.+>$/.test(type)) { const [, subtype, rawLength] = type.match(/^array<(.+),\s*(\d+)>/); @@ -901,7 +985,8 @@ class RendererWebGPU extends Renderer3D { align: elemAlign, size: elemSize, items: elemItems, - pack: elemPack = (data) => [...data] + pack: elemPack = (data) => [...data], + baseType: elemBaseType } = baseAlignAndSize(subtype); const stride = Math.ceil(elemSize / elemAlign) * elemAlign; const pack = (data) => { @@ -920,6 +1005,7 @@ class RendererWebGPU extends Renderer3D { size: stride * length, items: elemItems * length, pack, + baseType: elemBaseType }; } throw new Error(`Unknown type in WGSL struct: ${type}`); @@ -927,7 +1013,7 @@ class RendererWebGPU extends Renderer3D { while ((match = elementRegex.exec(structBody)) !== null) { const [_, location, name, type] = match; - const { size, align, pack } = baseAlignAndSize(type); + const { size, align, pack, baseType } = baseAlignAndSize(type); offset = Math.ceil(offset / align) * align; const offsetEnd = offset + size; elements[name] = { @@ -938,7 +1024,8 @@ class RendererWebGPU extends Renderer3D { size, offset, offsetEnd, - pack + pack, + baseType }; index++; offset = offsetEnd; @@ -1184,6 +1271,17 @@ class RendererWebGPU extends Renderer3D { return this._defaultLineShader; } + _getFontShader() { + if (!this._defaultFontShader) { + this._defaultFontShader = new Shader( + this, + fontVertexShader, + fontFragmentShader + ); + } + return this._defaultFontShader; + } + ////////////////////////////////////////////// // Setting ////////////////////////////////////////////// diff --git a/src/webgpu/shaders/font.js b/src/webgpu/shaders/font.js new file mode 100644 index 0000000000..7cd92c6bff --- /dev/null +++ b/src/webgpu/shaders/font.js @@ -0,0 +1,283 @@ +const uniforms = ` +struct Uniforms { + uModelViewMatrix: mat4x4, + uProjectionMatrix: mat4x4, + uStrokeImageSize: vec2, + uCellsImageSize: vec2, + uGridImageSize: vec2, + uGridOffset: vec2, + uGridSize: vec2, + uGlyphRect: vec4, + uGlyphOffset: f32, + uMaterialColor: vec4, +}; +`; + +export const fontVertexShader = ` +struct VertexInput { + @location(0) aPosition: vec3, + @location(1) aTexCoord: vec2, +}; + +struct VertexOutput { + @builtin(position) Position: vec4, + @location(0) vTexCoord: vec2, +}; + +${uniforms} +@group(0) @binding(0) var uniforms: Uniforms; + +@vertex +fn main(input: VertexInput) -> VertexOutput { + var output: VertexOutput; + var positionVec4 = vec4(input.aPosition, 1.0); + + // scale by the size of the glyph's rectangle + positionVec4.x = positionVec4.x * (uniforms.uGlyphRect.z - uniforms.uGlyphRect.x); + positionVec4.y = positionVec4.y * (uniforms.uGlyphRect.w - uniforms.uGlyphRect.y); + + // Expand glyph bounding boxes by 1px on each side to give a bit of room + // for antialiasing + let newOrigin = (uniforms.uModelViewMatrix * vec4(0.0, 0.0, 0.0, 1.0)).xyz; + let newDX = (uniforms.uModelViewMatrix * vec4(1.0, 0.0, 0.0, 1.0)).xyz; + let newDY = (uniforms.uModelViewMatrix * vec4(0.0, 1.0, 0.0, 1.0)).xyz; + let pixelScale = vec2( + 1.0 / length(newOrigin - newDX), + 1.0 / length(newOrigin - newDY) + ); + let offset = pixelScale * normalize(input.aTexCoord - vec2(0.5, 0.5)); + let textureOffset = offset * (1.0 / vec2( + uniforms.uGlyphRect.z - uniforms.uGlyphRect.x, + uniforms.uGlyphRect.w - uniforms.uGlyphRect.y + )); + + // move to the corner of the glyph + positionVec4.x = positionVec4.x + uniforms.uGlyphRect.x; + positionVec4.y = positionVec4.y + uniforms.uGlyphRect.y; + + // move to the letter's line offset + positionVec4.x = positionVec4.x + uniforms.uGlyphOffset; + + positionVec4.x = positionVec4.x + offset.x; + positionVec4.y = positionVec4.y + offset.y; + + output.Position = uniforms.uProjectionMatrix * uniforms.uModelViewMatrix * positionVec4; + output.vTexCoord = input.aTexCoord + textureOffset; + + return output; +} +`; + +export const fontFragmentShader = ` +struct FragmentInput { + @location(0) vTexCoord: vec2, +}; + +${uniforms} +@group(0) @binding(0) var uniforms: Uniforms; + +@group(1) @binding(0) var uSamplerStrokes: texture_2d; +@group(1) @binding(1) var uSamplerStrokes_sampler: sampler; +@group(1) @binding(2) var uSamplerRowStrokes: texture_2d; +@group(1) @binding(3) var uSamplerRowStrokes_sampler: sampler; +@group(1) @binding(4) var uSamplerRows: texture_2d; +@group(1) @binding(5) var uSamplerRows_sampler: sampler; +@group(1) @binding(6) var uSamplerColStrokes: texture_2d; +@group(1) @binding(7) var uSamplerColStrokes_sampler: sampler; +@group(1) @binding(8) var uSamplerCols: texture_2d; +@group(1) @binding(9) var uSamplerCols_sampler: sampler; + +// some helper functions +fn ROUND_f32(v: f32) -> i32 { return i32(floor(v + 0.5)); } +fn ROUND_vec2(v: vec2) -> vec2 { return vec2(floor(v + 0.5)); } +fn saturate_f32(v: f32) -> f32 { return clamp(v, 0.0, 1.0); } +fn saturate_vec2(v: vec2) -> vec2 { return clamp(v, vec2(0.0), vec2(1.0)); } + +fn mul_f32_i32(v1: f32, v2: i32) -> i32 { + return i32(floor(v1 * f32(v2))); +} + +fn mul_vec2_ivec2(v1: vec2, v2: vec2) -> vec2 { + return vec2(floor(v1 * vec2(v2) + 0.5)); +} + +// unpack a 16-bit integer from a float vec2 +fn getInt16(v: vec2) -> i32 { + let iv = ROUND_vec2(v * 255.0); + return iv.x * 128 + iv.y; +} + +const minDistance: f32 = 1.0/8192.0; +const hardness: f32 = 1.05; // amount of antialias + +// the maximum number of curves in a glyph +const N: i32 = 250; + +// retrieves an indexed pixel from a texture +fn getTexel(texture: texture_2d, samp: sampler, pos: i32, size: vec2) -> vec4 { + let width = size.x; + let x = pos % width; + let y = pos / width; + + return textureLoad(texture, vec2(x, y), 0); +} + +fn calculateCrossings(p0: vec2, p1: vec2, p2: vec2, vTexCoord: vec2, pixelScale: vec2) -> array, 2> { + // get the coefficients of the quadratic in t + var a = p0 - p1 * 2.0 + p2; + var b = p0 - p1; + a = vec2( + select(a.x, sign(a.x) * 1e-6, abs(a.x) < 1e-6), + select(a.y, sign(a.y) * 1e-6, abs(a.y) < 1e-6) + ); + b = vec2( + select(b.x, sign(b.x) * 1e-6, abs(b.x) < 1e-6), + select(b.y, sign(b.y) * 1e-6, abs(b.y) < 1e-6) + ); + let c = p0 - vTexCoord; + + // found out which values of 't' it crosses the axes + let surd = sqrt(max(vec2(0.0), b * b - a * c)); + let t1 = ((b - surd) / a).yx; + let t2 = ((b + surd) / a).yx; + + // approximate straight lines to avoid rounding errors + var t1_fixed = t1; + var t2_fixed = t2; + if (abs(a.y) < 0.001) { + t1_fixed.x = c.y / (2.0 * b.y); + t2_fixed.x = c.y / (2.0 * b.y); + } + + if (abs(a.x) < 0.001) { + t1_fixed.y = c.x / (2.0 * b.x); + t2_fixed.y = c.x / (2.0 * b.x); + } + + // plug into quadratic formula to find the coordinates of the crossings + let C1 = ((a * t1_fixed - b * 2.0) * t1_fixed + c) * pixelScale; + let C2 = ((a * t2_fixed - b * 2.0) * t2_fixed + c) * pixelScale; + + return array, 2>(C1, C2); +} + +fn coverageX(p0: vec2, p1: vec2, p2: vec2, vTexCoord: vec2, pixelScale: vec2, coverage: ptr>, weight: ptr>) { + let crossings = calculateCrossings(p0, p1, p2, vTexCoord, pixelScale); + let C1 = crossings[0]; + let C2 = crossings[1]; + + // determine on which side of the x-axis the points lie + let y0 = p0.y > vTexCoord.y; + let y1 = p1.y > vTexCoord.y; + let y2 = p2.y > vTexCoord.y; + + // could we be under the curve (after t1)? + if ((y1 && !y2) || (!y1 && y0)) { + // add the coverage for t1 + (*coverage).x = (*coverage).x + saturate_f32(C1.x + 0.5); + // calculate the anti-aliasing for t1 + (*weight).x = min((*weight).x, abs(C1.x)); + } + + // are we outside the curve (after t2)? + if ((y1 && !y0) || (!y1 && y2)) { + // subtract the coverage for t2 + (*coverage).x = (*coverage).x - saturate_f32(C2.x + 0.5); + // calculate the anti-aliasing for t2 + (*weight).x = min((*weight).x, abs(C2.x)); + } +} + +// this is essentially the same as coverageX, but with the axes swapped +fn coverageY(p0: vec2, p1: vec2, p2: vec2, vTexCoord: vec2, pixelScale: vec2, coverage: ptr>, weight: ptr>) { + let crossings = calculateCrossings(p0, p1, p2, vTexCoord, pixelScale); + let C1 = crossings[0]; + let C2 = crossings[1]; + + let x0 = p0.x > vTexCoord.x; + let x1 = p1.x > vTexCoord.x; + let x2 = p2.x > vTexCoord.x; + + if ((x1 && !x2) || (!x1 && x0)) { + (*coverage).y = (*coverage).y - saturate_f32(C1.y + 0.5); + weight.y = min(weight.y, abs(C1.y)); + } + + if ((x1 && !x0) || (!x1 && x2)) { + (*coverage).y = (*coverage).y + saturate_f32(C2.y + 0.5); + (*weight).y = min((*weight).y, abs(C2.y)); + } +} + +@fragment +fn main(input: FragmentInput) -> @location(0) vec4 { + // var pixelScale: vec2; + var coverage: vec2 = vec2(0.0); + var weight: vec2 = vec2(0.5); + let pixelScale = hardness / fwidth(input.vTexCoord); + + // which grid cell is this pixel in? + let gridCoord = vec2(floor(input.vTexCoord * vec2(uniforms.uGridSize))); + + // intersect curves in this row + { + // the index into the row info bitmap + let rowIndex = gridCoord.y + uniforms.uGridOffset.y; + // fetch the info texel + let rowInfo = getTexel(uSamplerRows, uSamplerRows_sampler, rowIndex, uniforms.uGridImageSize); + // unpack the rowInfo + let rowStrokeIndex = getInt16(rowInfo.xy); + let rowStrokeCount = getInt16(rowInfo.zw); + + for (var iRowStroke = 0; iRowStroke < N; iRowStroke = iRowStroke + 1) { + if (iRowStroke >= rowStrokeCount) { + break; + } + + // each stroke is made up of 3 points: the start and control point + // and the start of the next curve. + // fetch the indices of this pair of strokes: + let strokeIndices = getTexel(uSamplerRowStrokes, uSamplerRowStrokes_sampler, rowStrokeIndex + iRowStroke, uniforms.uCellsImageSize); + + // unpack the stroke index + let strokePos = getInt16(strokeIndices.xy); + + // fetch the two strokes + let stroke0 = getTexel(uSamplerStrokes, uSamplerStrokes_sampler, strokePos + 0, uniforms.uStrokeImageSize); + let stroke1 = getTexel(uSamplerStrokes, uSamplerStrokes_sampler, strokePos + 1, uniforms.uStrokeImageSize); + + // calculate the coverage + coverageX(stroke0.xy, stroke0.zw, stroke1.xy, input.vTexCoord, pixelScale, &coverage, &weight); + } + } + + // intersect curves in this column + { + let colIndex = gridCoord.x + uniforms.uGridOffset.x; + let colInfo = getTexel(uSamplerCols, uSamplerCols_sampler, colIndex, uniforms.uGridImageSize); + let colStrokeIndex = getInt16(colInfo.xy); + let colStrokeCount = getInt16(colInfo.zw); + + for (var iColStroke = 0; iColStroke < N; iColStroke = iColStroke + 1) { + if (iColStroke >= colStrokeCount) { + break; + } + + let strokeIndices = getTexel(uSamplerColStrokes, uSamplerColStrokes_sampler, colStrokeIndex + iColStroke, uniforms.uCellsImageSize); + + let strokePos = getInt16(strokeIndices.xy); + let stroke0 = getTexel(uSamplerStrokes, uSamplerStrokes_sampler, strokePos + 0, uniforms.uStrokeImageSize); + let stroke1 = getTexel(uSamplerStrokes, uSamplerStrokes_sampler, strokePos + 1, uniforms.uStrokeImageSize); + coverageY(stroke0.xy, stroke0.zw, stroke1.xy, input.vTexCoord, pixelScale, &coverage, &weight); + } + } + + weight = saturate_vec2(vec2(1.0) - weight * 2.0); + let distance = max(weight.x + weight.y, minDistance); // manhattan approx. + let antialias = abs(dot(coverage, weight) / distance); + let cover = min(abs(coverage.x), abs(coverage.y)); + var outColor = vec4(uniforms.uMaterialColor.rgb, 1.0) * uniforms.uMaterialColor.a; + outColor = outColor * saturate_f32(max(antialias, cover)); + return outColor; +} +`; diff --git a/test/unit/visual/cases/webgpu.js b/test/unit/visual/cases/webgpu.js index bffc88c219..8eef145710 100644 --- a/test/unit/visual/cases/webgpu.js +++ b/test/unit/visual/cases/webgpu.js @@ -306,4 +306,304 @@ visualSuite("WebGPU", function () { }, ); }); + + visualSuite('Typography', function () { + visualSuite('textFont', function () { + visualTest('with a font file in WebGPU', async function (p5, screenshot) { + await p5.createCanvas(100, 100, p5.WEBGPU); + const font = await p5.loadFont( + '/unit/assets/Inconsolata-Bold.ttf' + ); + p5.textFont(font); + p5.textAlign(p5.LEFT, p5.TOP); + p5.textSize(35); + p5.text('p5*js', -p5.width / 2, -p5.height / 2 + 10, p5.width); + await screenshot(); + }); + }); + + visualSuite('textWeight', function () { + visualTest('can control variable fonts from files in WebGPU', async function (p5, screenshot) { + await p5.createCanvas(100, 100, p5.WEBGPU); + const font = await p5.loadFont( + '/unit/assets/BricolageGrotesque-Variable.ttf' + ); + for (let weight = 400; weight <= 800; weight += 100) { + p5.push(); + p5.background(255); + p5.translate(-p5.width/2, -p5.height/2); + p5.textFont(font); + p5.textAlign(p5.LEFT, p5.TOP); + p5.textSize(35); + p5.textWeight(weight); + p5.text('p5*js', 0, 10, p5.width); + p5.pop(); + await screenshot(); + } + }); + }); + + visualSuite('textAlign', function () { + visualSuite('webgpu mode', () => { + visualTest('all alignments with single word', async function (p5, screenshot) { + const alignments = [ + { alignX: p5.LEFT, alignY: p5.TOP }, + { alignX: p5.CENTER, alignY: p5.TOP }, + { alignX: p5.RIGHT, alignY: p5.TOP }, + { alignX: p5.LEFT, alignY: p5.CENTER }, + { alignX: p5.CENTER, alignY: p5.CENTER }, + { alignX: p5.RIGHT, alignY: p5.CENTER }, + { alignX: p5.LEFT, alignY: p5.BOTTOM }, + { alignX: p5.CENTER, alignY: p5.BOTTOM }, + { alignX: p5.RIGHT, alignY: p5.BOTTOM } + ]; + + await p5.createCanvas(300, 300, p5.WEBGPU); + p5.translate(-p5.width/2, -p5.height/2); + p5.textSize(60); + const font = await p5.loadFont( + '/unit/assets/Inconsolata-Bold.ttf' + ); + p5.textFont(font); + for (const alignment of alignments) { + p5.background(255); + p5.textAlign(alignment.alignX, alignment.alignY); + const bb = p5.textBounds('Single Line', p5.width / 2, p5.height / 2); + p5.push(); + p5.push() + p5.noFill(); + p5.stroke('red'); + p5.rect(bb.x, bb.y, bb.w, bb.h); + p5.pop() + p5.fill(0) + p5.text('Single Line', p5.width / 2, p5.height / 2); + p5.pop(); + await screenshot(); + } + }); + + visualTest('all alignments with single line', async function (p5, screenshot) { + const alignments = [ + { alignX: p5.LEFT, alignY: p5.TOP }, + { alignX: p5.CENTER, alignY: p5.TOP }, + { alignX: p5.RIGHT, alignY: p5.TOP }, + { alignX: p5.LEFT, alignY: p5.CENTER }, + { alignX: p5.CENTER, alignY: p5.CENTER }, + { alignX: p5.RIGHT, alignY: p5.CENTER }, + { alignX: p5.LEFT, alignY: p5.BOTTOM }, + { alignX: p5.CENTER, alignY: p5.BOTTOM }, + { alignX: p5.RIGHT, alignY: p5.BOTTOM } + ]; + + await p5.createCanvas(300, 300, p5.WEBGPU); + p5.translate(-p5.width/2, -p5.height/2); + p5.textSize(45); + const font = await p5.loadFont( + '/unit/assets/Inconsolata-Bold.ttf' + ); + p5.textFont(font); + for (const alignment of alignments) { + p5.background(255); + p5.textAlign(alignment.alignX, alignment.alignY); + p5.text('Single Line', p5.width / 2, p5.height / 2); + const bb = p5.textBounds('Single Line', p5.width / 2, p5.height / 2); + p5.push(); + p5.noFill(); + p5.stroke('red'); + p5.rect(bb.x, bb.y, bb.w, bb.h); + p5.pop(); + await screenshot(); + } + }); + + visualTest('all alignments with multi-lines and wrap word', + async function (p5, screenshot) { + const alignments = [ + { alignX: p5.LEFT, alignY: p5.TOP }, + { alignX: p5.CENTER, alignY: p5.TOP }, + { alignX: p5.RIGHT, alignY: p5.TOP }, + { alignX: p5.LEFT, alignY: p5.CENTER }, + { alignX: p5.CENTER, alignY: p5.CENTER }, + { alignX: p5.RIGHT, alignY: p5.CENTER }, + { alignX: p5.LEFT, alignY: p5.BOTTOM }, + { alignX: p5.CENTER, alignY: p5.BOTTOM }, + { alignX: p5.RIGHT, alignY: p5.BOTTOM } + ]; + + await p5.createCanvas(150, 100, p5.WEBGPU); + p5.translate(-p5.width/2, -p5.height/2); + p5.textSize(20); + p5.textWrap(p5.WORD); + const font = await p5.loadFont( + '/unit/assets/Inconsolata-Bold.ttf' + ); + p5.textFont(font); + + let xPos = 20; + let yPos = 20; + const boxWidth = 100; + const boxHeight = 60; + + for (const alignment of alignments) { + p5.background(255); + p5.push(); + p5.textAlign(alignment.alignX, alignment.alignY); + + p5.noFill(); + p5.strokeWeight(2); + p5.stroke(200); + p5.rect(xPos, yPos, boxWidth, boxHeight); + + p5.fill(0); + p5.noStroke(); + p5.text( + 'A really long text that should wrap automatically as it reaches the end of the box', + xPos, + yPos, + boxWidth, + boxHeight + ); + const bb = p5.textBounds( + 'A really long text that should wrap automatically as it reaches the end of the box', + xPos, + yPos, + boxWidth, + boxHeight + ); + p5.noFill(); + p5.stroke('red'); + p5.rect(bb.x, bb.y, bb.w, bb.h); + p5.pop(); + + await screenshot(); + } + } + ); + + visualTest( + 'all alignments with multi-lines and wrap char', + async function (p5, screenshot) { + const alignments = [ + { alignX: p5.LEFT, alignY: p5.TOP }, + { alignX: p5.CENTER, alignY: p5.TOP }, + { alignX: p5.RIGHT, alignY: p5.TOP }, + { alignX: p5.LEFT, alignY: p5.CENTER }, + { alignX: p5.CENTER, alignY: p5.CENTER }, + { alignX: p5.RIGHT, alignY: p5.CENTER }, + { alignX: p5.LEFT, alignY: p5.BOTTOM }, + { alignX: p5.CENTER, alignY: p5.BOTTOM }, + { alignX: p5.RIGHT, alignY: p5.BOTTOM } + ]; + + await p5.createCanvas(150, 100, p5.WEBGPU); + p5.translate(-p5.width/2, -p5.height/2); + p5.textSize(19); + p5.textWrap(p5.CHAR); + const font = await p5.loadFont( + '/unit/assets/Inconsolata-Bold.ttf' + ); + p5.textFont(font); + + let xPos = 20; + let yPos = 20; + const boxWidth = 100; + const boxHeight = 60; + + for (const alignment of alignments) { + p5.background(255); + p5.push(); + p5.textAlign(alignment.alignX, alignment.alignY); + + p5.noFill(); + p5.strokeWeight(2); + p5.stroke(200); + p5.rect(xPos, yPos, boxWidth, boxHeight); + + p5.fill(0); + p5.noStroke(); + p5.text( + 'A really long text that should wrap automatically as it reaches the end of the box', + xPos, + yPos, + boxWidth, + boxHeight + ); + const bb = p5.textBounds( + 'A really long text that should wrap automatically as it reaches the end of the box', + xPos, + yPos, + boxWidth, + boxHeight + ); + p5.noFill(); + p5.stroke('red'); + p5.rect(bb.x, bb.y, bb.w, bb.h); + p5.pop(); + + await screenshot(); + } + } + ); + + visualTest( + 'all alignments with multi-line manual text', + async function (p5, screenshot) { + const alignments = [ + { alignX: p5.LEFT, alignY: p5.TOP }, + { alignX: p5.CENTER, alignY: p5.TOP }, + { alignX: p5.RIGHT, alignY: p5.TOP }, + { alignX: p5.LEFT, alignY: p5.CENTER }, + { alignX: p5.CENTER, alignY: p5.CENTER }, + { alignX: p5.RIGHT, alignY: p5.CENTER }, + { alignX: p5.LEFT, alignY: p5.BOTTOM }, + { alignX: p5.CENTER, alignY: p5.BOTTOM }, + { alignX: p5.RIGHT, alignY: p5.BOTTOM } + ]; + + await p5.createCanvas(150, 100, p5.WEBGPU); + p5.translate(-p5.width/2, -p5.height/2); + p5.textSize(20); + + const font = await p5.loadFont( + '/unit/assets/Inconsolata-Bold.ttf' + ); + p5.textFont(font); + + let xPos = 20; + let yPos = 20; + const boxWidth = 100; + const boxHeight = 60; + + for (const alignment of alignments) { + p5.background(255); + p5.push(); + p5.textAlign(alignment.alignX, alignment.alignY); + + p5.noFill(); + p5.stroke(200); + p5.strokeWeight(2); + p5.rect(xPos, yPos, boxWidth, boxHeight); + + p5.fill(0); + p5.noStroke(); + p5.text('Line 1\nLine 2\nLine 3', xPos, yPos, boxWidth, boxHeight); + const bb = p5.textBounds( + 'Line 1\nLine 2\nLine 3', + xPos, + yPos, + boxWidth, + boxHeight + ); + p5.noFill(); + p5.stroke('red'); + p5.rect(bb.x, bb.y, bb.w, bb.h); + p5.pop(); + + await screenshot(); + } + } + ); + }); + }); + }); }); diff --git a/test/unit/visual/screenshots/WebGPU/Canvas Resizing/Main canvas drawing after resize/000.png b/test/unit/visual/screenshots/WebGPU/Canvas Resizing/Main canvas drawing after resize/000.png index 96849ce04c21325da234ba7192bc8c1bc63bce67..eeefb557bee12ea18f03d64a9faf5350a49a65c4 100644 GIT binary patch delta 246 zcmVx8`|1S-vU3j`0}d%`>$E5c1(i><^i&}u1mpHL*#3Sbq~R;i8e+Zq~Fi?5PVL& z``-m;9W#wY9xj+MzLH~tqfVfw*IW~$bFWjx zSVbQ5O^m6cjuH2SI>?LTJ)IZ^5Rw_OxhYs+Fofi;PsAYo?%ipy7{J_;#M!e9W{tDF w1J4ZX&taL?s3+ZT0V1+#eTh7Vl93a%PWb- zjzHgiyB~qNA58!HNV`|Pe%M&600V0!+1)1rsJn4-4pB0a00000NkvXXu0mjfCL>W} diff --git a/test/unit/visual/screenshots/WebGPU/Framebuffers/Auto-sized framebuffer after canvas resize/000.png b/test/unit/visual/screenshots/WebGPU/Framebuffers/Auto-sized framebuffer after canvas resize/000.png index 01be2eb74e88adf3364c6690d614b349cfa91f03..096873ebad1954614a7c152ca22186eb84ebbb1d 100644 GIT binary patch delta 279 zcmV+y0qFjx0kQ&+B!A~gL_t(|0qm5~4TCTYL=P4#>_X=N=4f{zo26VUOZ{*XTe9FM zmQIM+@rzG^alw-B(1@F&gD*2)wfdc3h?F*s3f2mv*E%@b(PBTh)e{m9%*YtN+OUIy zGhAQ-2qj!ND>hKf!4YOK8xv6K&D{TiN_Dbs20xI{iuJU;RevLSGuSeXrN#}G4N*_? z#0e8x!Ta5+k-QnapLbU_MLf-5PF$OaIeu8yLz%&B$i@Uve5ZJz83&kyBV1tiC4l<< zw=xJNoH>u&U{CW}Z3oxf3C*ciFT)d&(oU>`wZiDN4vu!T*zW@X0RR6TaaA<{000I_ dL_t&o0GVu=G}1JMMKBYy%0Nklekpo9kl!bXhNjs3*3rK6CxiL;st&fA}_2eJ~XMwQjtK7?KfIk1(*&vw)3AyNN zqHvG1bhxY{9sHV05^}Lh*lwR&S0a5<&U>9i^5-&ccUENF+2Px$MoC0LRA@u(Spg1%FbIU7xtBR#Hh;!O zQ;k_{hm~q|XJWEMHaWQC(6NXJGgIymk#M=yxR4uwthDf~&Y>eIJ)t9s%2*2nYJpm@ z0#?dc<7R4Q-S8q8gCSld*KC?__O1!H6Q~s^$8&jA0WN+6DOR##ZAD?*`Eu_(2tiH6sa{b(<4)rQ#2A5wrOoM0#U%Z z_cwKuRB5 zRv9?^t(|>Ig=^f>sGiZ)S{iFTk1Y9r$u+)KVRDpq@sa5U*4@ZE&TT~j5U2kx(UFws zyt!}vJvjT{I6HjgtJRw|a&!f)30mVYew!V?pjM$ct=zZ9-A^Me8r;^L-dl#a1jh2# Q@&Et;07*qoM6N<$g0<+m&M7)3I5(1Hrt6wMj zy_5Fzc)QtutA8Z{Mv7cQaDy1>oqWt!G?5G#C^QS!rI3yq}xb@_2NE`UPS*qb#E3j z`<95*i>B)6MNHL=<;_C6;K;?gj4KGZ{POiO3t=+zPk)KvvnQ_KCBoCgE#&4+t|vAN zPxR`;*MkALgmmF~EncPNi%en=0oLn=TL=vxbGynH0}c^j`AjcQxEzTWLj*z(k3mon z#XjcxYZqL^fxBl3%%R#o_RzkF5H5hwGKfG7asMPO!n1=kD>=@{5$wT$EfK|pL^6#2)nTRQ|78eppL3AFzo*~VOK+a;m^qMWC{?Bt;lg4Zzrd$31)Pz(Xa(4UX zNcPAo1DYe12b|r$Ig&lH%7ErbPx$!%0LzRA@u(m=DduFc8Jx9N+*PfCF#< z4wwUQKyUyKh68W_4!{98z`@8%v#@;Mn(pu2SniT$BxP%V_x7&sA|OpMfQSsFXDXT^ zJre|`h$+%h2JF@9D6zF4Yl_rPT}L-oRwQM`?l>Y;-~Jg$NPm>~2*|h58<;!+UPGa- z2q%7@x%L_tgG?X5;_9G$p$hVx0Ou8OSvn9x6$JqZGQatO)IbPvih*Pe@OPOYP<#T` z$$xcJKRf{m2BM>LwLLd%8H3&hNCPB~a_Rcd5age5JWbiD> z1lb-GKv_BGs9|y@B zUR05`UoAC724i;mf)$HC@$IY1dWQz5KWKQBb)+rB=PQ;92$kTXc-%DGqU6<30pXy7 z*)-OL>D@LyGc=WvH%yZ|h@MJGkkb!o&`kuspi)WOh4oP5^CF9*Wr zJHPh=Bm*Qs5y($`d?>qag#IU@NN5sT{gpq^X-zaVu7QLyB;9X{NM}4Wo+%Q_kaWK( mBAxNjc&11wL(=_&QRD+413BITG@jo80000i)0-^+vBYy$cNklhr zk~Sl~Bi<3C3HVpbXs|Pv^^VLuI-`Tj;mFlXKw2Wo$mMV(YJUo8%6muBA5!6-ZREa1(EtMy2a#9kiA_ZO;PeF|bdZDlNst+U zOLRL{P~IKIVY~o(XuT${Q!{zQ!#AiNR$8BCd$| zQ|^!ufSUU*dy+HYzyzc(R9+!*q)dD4Z>Ilj+)q=GxPRg_=k*{}SKj*Huk>(jG)oV% z>&l-ShxBkQG)oVnsXoVNJ;)q~JyolTj+||Uq$Q$gCW1sAPE+247`ve^?;TN(%=mzJ z#CX8!^4<~k$cztoM~nxoF2692`~v_0|Nlrd#C8Ax00v1!K~w_(Iw>!X26UK?00000 MNkvXXt^-0~g0fukk^lez delta 265 zcmV+k0rvi)1fv3wBYyw^b5ch_0Itp)=>Px#-AP12RA@u(n9&V_Fc^e?R;mNwJT8GQ z;XF8iuB7m2gf`_kLz<6QdRP9tuL;nll-|+6)iKDyTBMPK6%sT;Bc=pE%M|QpTN>FM zok^H&3>r&m_QAoZDQ3L2U7Yx|6h5%JJ4DXRO3IDdby+8?dkw86FbDB6PA ze_)W`5Est~ja1MES}S3JRs}_iOF;{?REAdcpaoi>l~3R=XQY|ecoR(}+x@>sN;{hF zGe}e4>?TSqw*33G#44E5h!CDdN+Xt71ydRk!m~(e#1gAuN+Uve7U>7x`npZMO%MzK P0000L2KuCy3eVdUdC@{DEyM-%35iIL+l5GP4}W+*3khg8YgA5zWmCYW zoJc^kS)(2#k>6*cTDgHfmT&iKvM17MA&nN|0{ZU9k#)0O2OnU=$s_{u zho=K=-?vom%6}eMqt4Njgbexr04M_+AL6F^?HhnrEzyI13)~I9Og=B=BiGnzQzf0V?#(5_vghSfY6HQ3FZfP!( z5@I4FsYOdfLfUjT3o*G8v-S@F0RR7U;vEtI000I_L_t&o0D83@j)WQ2RsaA107*qo IM6N<$f~AYnssI20 delta 376 zcmV-;0f+wU1Cj%fBYyw^b5ch_0Itp)=>Px$Oi4sRRA@u(n9UJ_Fc5{8GHk%yV*zRb z&;=cESMPRU7f^s&0J+BoC<7;qGV;4ljIzY##*k$4?R#&@B#=^;;K37k2a-$aRF{=f zPS?X0s}*68fl{#|n1=?w|B1Ck0kACa8r>EEdeIn1%UYoVh=1PbKB)xAnq7}UaQ%Bn zjAw)u0bpyfwH~M6-XjWUgfn895P&>6tx<1sb#UQMRwFmsWe6TJBk%_Db5h3br zup+F;mny*4`n0ezx4&yvtM1LtWQuqXA}#{NRaAnfyR=t$(uzcf2*Q2X*?6|kIzVJW zh9I6)Z5Xm3r*Ytf_KvuW>oFujFg2dLKc0`EH@bSAHOQ;INP?Iym24B#THb(=&xnx% z%gJoI))>sJ{v)peG3uB{uXWv}+5sXNb~NQZ0~o}nYQLT{*)iTVD1*4J=%?#>qsSc- W?H*jB5ljmJ0000<14YB+u^SR>D3jm$K&~a?|;Lq{WTy(^h6yyLz^rK zh0GwPdtU%kSBlDAFlLY<$o$+469*}$J|Y{$S9it)V!BomSk)DVSHhS;SlnfUt}xds zm>9@JAVrGSb%kqIz^FmO*NTcKMi^SXgf(gP+-R%@86|Md3tcIB3QP@9kITCLc@D&I z4K>2Q2hvw&SAU9?82vIfK2;qNPsnh>Z%v~;1fY%L?*!w43S_*!iNu@$1Ph-BkpxR~ zRA;dBAO>-krMDo<{yu6|>_BaG%sIXVappa~?H@!ua=t>v!2ZcK9U%a1g72?D@{syYk+Mj1lQTOa}5S_c3?hHVz>oTDIsU+5jqQ4op&_P`tmAxv)op8q2B^A`UX009605(=DE00006 dNklPx$lu1NERA@u(n$fz#APj@q`#&`2bIOq^ zvrU_pZR$0S{U#{|TuLdmI6t*MAF(Wp^J%uP-)41z^bq|kWCOWCHV{pTN$PMwMs}*T zWU68Y8LIJO@65EAKr|4IDGMeXo5r9A#wt7{ErHl>4X{!jBLx3=BrUr?yl~e@UoSL5Pa3D>cb#DQ5(l`h?AL2IZ z0VApMT_9Qc7^=fmq$;iJVX9k&SE2bh-L73~v^*|=(V_ofq`NakC3_;(t zHG8eX4@7LDgnt1*(7B4ViCJ0725DPWdUeYJ8SS;HFv@1JKQH@uKl1i+!YMnYIDl%X zGIH+{uqy&LOcV%OfLI{XBDlFB^IpA$tUq8r)Yd{3Y2#OA&~j|B)ei(w2v)^lt8hhH z#Z%kMXsbMsdmk2m}IwvV}k(5C{YUfwF}_pa}1Mcd=(5%kJ#X*cr#i?3nYv|L@*AbLZa6 zNcvjas&9u{F4R$(1B zVH={b3;S>g$8ZYga0%CN3o*EdM|cM7V1z}GDbm}Q39>}VtMp7)5?>~$B4sbqBlki) ze@P~O5U}nQdXnzBnG?Coiu~bcfT(zoVmytMoHGh0VHJQ@sp4^U%j5J*&ay2M00EnL z&7E}3cwmpe5PuYrx*O?|0qu*xMn9FkH=kmQj~eYewmper%fgo2w`%ni5vvzNU^o9hu_Vp2 zyy9lecr5+md}L}tnjNP^TZ3*xSQ9sLJMgD?kvVC4Qto!Aq`ZR;k^N`(r%0+_lz5q(PS1e=IarjplmJO_7Zush@pfeyd!P%{HnPyrz*gAynLJoFC$0RR8Ku$8+2000I_ cL_t&o0F_1XGvUFtcmMzZ07*qoM6N<$f<%76Q~&?~ delta 248 zcmX@lKACBPayB$3xk6fx5|FU!MGX`~OR~(;A0MTq=tlHgTyeclgA$WPw8$ z*ODa;r?{3Za#+Q+q;Q$TE3PFA9cFPYS?X|$Ysq4VU0h3+JN)8GS>Vvdm9oU)7+1<7 zhhxkJnyoxnGh#1f1R^6%qxc9n#k!5OifXN+LanvSNUxaQ zddmdzE+YWmJxn8`ra6j78mMVRD!kkNHnLA-(ggNTYtmqwX@Bo|B6wGGq1ZHyoTpM9 zNV#~5c!^iZn?@wgq5FzJwh!;^Ccc{JTVZ68IYLIj4{;^1rcrXZ)7;E;*hnn~VrkTQ^*K*c#dwn!SC<8|i-8^DVzY^0XX(OY@0X2f2|2t-Djn)qvy z5k3~1Iow7}QEZp=`thX&m&-`2iK1Vv&l4?J_Z@YJT;MX2vGZ;agz$D?u%;1pn%>42 z&cy?PhzrktkwY*^(+E>De+y!{P&(h!h$2`@Z6nfTzI(O5Ph|4s!}R}iB4--&SK}xi z>8SqaL@7dOZOE0RR7W=||fD000I_L_t&o0CYt=tjD|)bpQYW07*qoM6N<$ Eg4+}DC;$Ke delta 402 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 b+E9}(O&6<%lI$_q00000NkvXXu0mjfqXOQI diff --git a/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-line manual text/000.png b/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-line manual text/000.png new file mode 100644 index 0000000000000000000000000000000000000000..f1698b508f6c9f6c67cc76e489dd0eb93076429b GIT binary patch literal 2325 zcma)8YdjNd8=stJPHl@q4pEH^IXp;tm}5*HiCNMxtY1(Tk->hxI zV$;b^@Nw8VsPzv3ppqQjNMR6m--5I-V&NohJ05QyGfP%SIiC zL6Q112hGUU<52BZwh42Af{sTNPd%Wb7tPz^ZP_f4)b3_PKg)4-O&Z=rhdiax640)) ziE1OcZdg&UB_!rKCszdHHb1&NtUejLX^(w~^iDbm=ZKJr44f8Vk)}LK( zuDn!WjJSO&{HHK%_>tC?Ze4MiYkEv=G)0+TDZ_?W6&(``vF9Gn8I z^>M@SUJj>xWwM#eB0(1yxfS<@_f*Hod3jLXgFl*R7Dd0crN&(s&*SFb9}e){yD&(5qS zVhkvm8?MDmKW)@D0wEJ?8}si(6imZr^v>hwIm*P%g>2KrkP#+g$Rgqk_VFI0g2Fy{*nG#yTGL+GRxx&G$3 zoki^a;pfDy;Ewt2>YxGQokV|q)Yz`j8(!z%1Nj%k2}dEA=BCG)V0)I+^_I5Jz4!$;eE3gY zB_#WSa?JOcE|P{?5&A=ocSRR=b!zEDjRf}d+01~0(9d^sBUUD;c9|+VPkfsY{`cPs zoPs7A@mSRKx8A#HhqD~3csy*!Lr*{bKqmg}>8-MRzz4DzUqhn*dgls zl*xQQI6F57QXaeJsF7Ig9X8hiooTo=z0mUzRu}t&Sjw6%9!>eaF~4U>cfbX)21P7n zxS~R*$&zRDpT?xTW35Y{Pztk(Bh4m(mZCM<{|{cGDD1n@!2*q^CevSQS;O}?2fxiz zvwXTI!!0cpYneiFyczE0+(a(+zQ#8N8qZecF(;Zdpq204SN>q7r3w^Ri^xRdpfue*D}IL?E5&? z8r5o?i&EWF@NSFNG7c*=k0SRkZ=oAC2ZHjC$rPe@R;OWCf^j%y-`1!dT|~c?S3YxA zaN=tD6U6TJ@-?%_^^?lKMx#TH;#?L!O4k1X-tcFHnSWt%}cW$5{J>%*2Zh`Slb@-Jo+J!o$vp$v`da&AY zMC`Y(ACF|%^~6SUI2=1LSe37S*t8Z^?P;6U*jlB>;sl07v0I&6)8zFqAvYv;5Vp3q zyz?9bj~a|zq*qsc$f){ytSmZo`NKV#WnmcVB%~zu1nEms*9{UfPZx~B^OBX1JuWc0 zr4D|M=i6oTM4JV2JzAg{I?;F{y!rO3Jfrw6-Ql5P$o^M9?a6U5XSg1Kufnd%+*U4?yzMjnVQutmDEJs)d*L8Xl>F^a`QW|=7`t|J$dU<)* znekdb@zG9rSo5e!-=wQos2-+8GYv8G^T%HL1=R^KP_B03t=i}JC5i6$E39CPf0d@V z!~W`Ti`iQcq*j_){TkobGWw&nm&o8f^xJ#VrGm#G`QRfKhZs<5f``fpJ&^hFub65% zZB(i}GiXH@0{WF@EBHyYd*d`7R^5-N24*~YcDn96ZgG%eoV&9^dT!OyR5^uNS$!?3 zJhop~jCe^PSb43L5E7VcK7zu?j)F%H~wwdmz__@YDh0oF3AN`Ww1_`MdsqoqhlnGU4Y)j&encvhzjd=kzqU>x z_+`97?GUdz!{jvC*t*)-e^at0m@j^tKcHx}7J! r@IVLvI5;Q_Eqy0wZp8lSYJ>qJ1#4sP0_-dVD*y*uXPakMJ_-K@RU~fX literal 0 HcmV?d00001 diff --git a/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-line manual text/001.png b/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-line manual text/001.png new file mode 100644 index 0000000000000000000000000000000000000000..aec0fbf45f06db8357c1839c697d6f713e4bb02b GIT binary patch literal 2316 zcmZvedpr|fAICQ&x4F$E_uOg8C3liqm`hVXY+{RrtXxt=L`X4=+~%%Gu5+0vnrm&Y zMTI2|%`MFR78U*0f6wcAo+mSD3Y3bxhx_PXgRb|DSd8>mB8;;czDQ6XyP5T#ZB$2w>?X|%P2Hg4vT z=d)2qA7K2t|L^RR4XLls2FokQo#T$YjL*aaKFz1+*(h>E3s*gT8imqHv5>qw)m(8V zezy4t!KtTD;S=1N(7pz?1?4v;Di~7}y78vg%$eSdp%a1Nrdyg*E0|9klIc=85;|2&<8i zk>N7LlZ?x_p;FNDSYu+TeV(N%qR_4)8TxFb3i%IRv!j2WNFl*`f{Vq4T^`aoe|bP_Y{7qK47vbnAz@3Fp%ErNqVt(VVGbh?2Y!OdRE4$h22Tb@nRn*oRSQ^(; zmm6GOcuy@NFJ82`d-v|v{71$90bAg7IA-bD^*ou8WXkrhp)v&5!eo1U9{zcI#4mlb zd%F&97W8UY)h(-JX_0VW%M@9H%n6rzm~cgDY1iw@_jv}0{Aq$NN1sTQS3i*}P|)C_ z$+ZD#*29i_)6_gA(oH8y;52tagIB-!&x3u+t9HQrrlOZVk(nOptkiti^~> zhdreGBYhX|zg!(9D-xuQE@gW(S%@XQ&pLU<=Uuio)^XBsCWW6PRlymDQ)k*&7iBAs zhJIUNH0xTdamQt^kMJhH3Ybhjhll|>JC;{gioO*LRe77($v&6Q&#eVR+EmOL&v?BX zbm%Q`xNpU1(CP%b!K*f|-sOmwb(SWY>cG_|`G)iNgTq2p32|=cb!VQuD-eOEHWs>qn!ar5xG$aLa&>l!(R{nOdTI)5oAu<$$)NH8bpl6QWM?LSK-87? z$miWDazkT{Hwd~1r@iIo1`1fUMUUyp0_3v^av}YPfGR|Ot=y`JxIyX$=$OH`SOB=6 z7ee2&jKa@Ko%QeFWiJ40kTwZy-5{0`1?I4Wsyd>+OR@C;p4n5vdME8jBlN;|d~*#0v6W6GUu4X}GKiXJo6YZoNBGT4jEM7U zI#dlPR0&#O1Cg=|i1w?+wAS$x+`SLYjjfJTvIJ#}m57xdk`6w`*`>*_M<~oR%|?bH z7_vIC+<4u62Q|1mMdK$&O>Zp>d1}^nQ)wOf-sK+bm%ln;rt`d}W`X0g1gX439o%-@kq1N$=69UUEEC^D$vda*Q_TS(UK zPN(ViBe-jwI}jvy+KnXguCGslk*hV-zy=@7tQ?Pg`v??^>&woF3)}y>{cE)wzGSIR zi~~tou7~C?z532zFvMxz8YG1q*@?e1;`IYBt#B4kyk*4Ntt#Q1FjGAKah;_-|?G#j+|GCW?<#lO~ zY#p6G9NwKQb6otQ8|9Au{GAns`c9-iSjEm7waYB4t{ZVmMqvP5y2-edy=Aq$K`&I& zI`i$}avr4GV}TfTnnUEl?;I`k(b+2^0uQ2w%M+WoMyr%AgIVL0keX&vb#>!_-`AIf zC2Zl%@KJ+7%5oru{XMoAf;fIGe??|EjrrJ_v(j%8gMn5 zqI@*{)ML&JKwC*eMvsK-O(H4w_K2QdtnqzKO#V}=@k&Yvtr`0}=wr&xMw}qTcsLqV zQqzW+PpE^j;$*hkQzTM{RkqC2CpR`=;cM%0>)#695!d(6KSEvatcg6uR3&OZ-5q>! zZ8%%vSu!2rPx)f>Wi(hVFZkk1+hx}0tNaqEQMB^9D$({^AN^^@x7ONw4t_(NB@ zYyV+Eg6D+A!Tmrn=RGSnnR4g+JArHhIn_uTDQq&$A2DJ$*_n{P|2axSeMrJ^#x++% z7z<~aned(9z$uF#OlsQ#y<{IyPkY8h;FO(JINYBwVNrOBKG z_54V{L-xcT=>k-o4L3#iFxupq5(kr$cF^TZnyT(9MCts-%{SCfnzOe!ys^EVF8^f} zpCC8%H_SxkpwfV!X)<8jEIZrebiDG>n`nkMIjFZ`#_-P}0N~G`N}&%L?3#xAZ!yCO YNN8Uhi$9II!Bzn9^A}(>XWe7|1ss56RsaA1 literal 0 HcmV?d00001 diff --git a/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-line manual text/002.png b/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-line manual text/002.png new file mode 100644 index 0000000000000000000000000000000000000000..4f4fc3243538b4f764d4391dc337e4dbcfcb8901 GIT binary patch literal 2318 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 literal 0 HcmV?d00001 diff --git a/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-line manual text/003.png b/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-line manual text/003.png new file mode 100644 index 0000000000000000000000000000000000000000..45eaf8a5f135e665c13178a29df3dbcb7a21b30b GIT binary patch literal 2321 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 literal 0 HcmV?d00001 diff --git a/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-line manual text/005.png b/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-line manual text/005.png new file mode 100644 index 0000000000000000000000000000000000000000..8fcf4b9b999bb142e4a4fc31d0b049de28013658 GIT binary patch literal 2305 zcmV+c3I6tpP)L<{1ES35u^U{Ozj)%7l}p@5k=6f z;DIQDXpF~-nka~f3X0+_9-z+lSJ<;X&efgiu0HAw^SWxPU%glLy^lU>swyMQA4b3d z1Tu&}jDQ0OFtHo}l}!>cv6uypAi%_O1Xec6#9|IOf&dfC5m-HFRC1G;Sdsx(#T^q% zl}JnOClgCD;HtP|VyO~o$^B$vNd{aMcT6l*A}zU}Of1QO|L-o?IVM)Hq~7Qd6U!TM2wW?eSb+JxrMZ1e$^#bI80C&6;Hj{*NC&(#)AN>FCj;G;Z8DQ*~r3RaaLN`czd_CEIme zf65YT>(;H*sZ*!8p!{Cgym@oV&(EheZQ2Ne{eIrf-|bLP;-jT`Ci-MiGM zPam_@r06lRQovkVT1uq`*}Z#rOpQZ_4pCWI8I2q{l17ahMF$QXpo0ewireSOlP9C} z2MroTQ?!==Ty0RVxVV^Fw{9J!W97%<#f!zQ&`KQ{x=gGTD1#V~-o1OL&?LHa>5_QP z!4+iKuwg=f)v8q@#Q`r1H*ek~*n0Ek4ZVN=o(2ydOz+;kqw(X%LnSc6#7Ys$2@@s| zB!!wdjjPXs1q;L#WX+m2qU_F{J0itlzcyrn^!V}P;wsgmMGLB|tV9u=I(15Z@fc6P*C%a$!gK8$fJEr%#HaCr9Y8InK<6H8JUKYjWX z@$~lXTQW+oUcCs?q)C&Aj?0;}-7~S$hR~dWu3fuE6l-g1>EXkNG18$!hlq~fGfXT= z5yTZKD=SNQGd|FxV8@OfNCG8F6RW(uoI;^cjOYW7A3l7b9zA*psjsgWfkz#bZP>6u z{I+t~vuBS`-@bjj7@AnLXb}w*{4lM~@y&j~+du$B!S=_3PKg z-$YlgT*0Zd1kY^nhf)8u4B1p!Sz_6QUxUqb@7|p-3Xeaw8*IeG6`5Eb4vw*De#fJD)gl zfBynpoO1uTD{rmT)#*G_`)zDZch+7lpulMWMFQ!qLdxdGrm;{KaGp$;+ zqJ8`J#pszS!1Lbu^XF;cz=09@K6kir;c0Y_wV1uYUq3S z?uiXLpoe^c_S}O!1dAw!4jn2c`pug+PfSV1qE##b!-F?;tO{0M8|zf@ZZk$Wyqm|= zVF*?)r)p)pcJ0J8W{?ES=b?`&$VeerPly!D?|1IpDQ;i--VNA-2zGB%1B@wPdx;dSQSU-rer%#{Kg9i_grL3$h@e+Xf@3E`D2;O`A5+ zvSrIewY_`yiqy>m6U#>QF@&L;%gBg^7IWP7b0bEKps{1e3c;;WFUP7NJ#Qt&)p5y^ zC87%U$ihxG;)jce=-#{7kf6J6+O(Dpbk=D?+E1G z7ABUY2w>p^TwyPVZr!?3VPRp6;Jj7Q-w0Q%SV3MP75d;Lv0&e`64qMwcX>IHb_Fpb zb@laxIwjiM-0%AT-o7o)mEjrZm%Z$q98rck(9hHcW08map}iTR%npG)$Y0pq=9h0_ zm)!j*ODuO`559Nxh!qY~Wkp3&F>Z^UNH?C2u?%^=F7ihCr@ET{EH0*e?E$_>yCv4* z|3|G|MjbPG-F~99=IT|~7_1(#G^jBknM^d5$TyK*L_y2wI$AdBo2VSS_u)l zZ^p&=#w+ literal 0 HcmV?d00001 diff --git a/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-line manual text/006.png b/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-line manual text/006.png new file mode 100644 index 0000000000000000000000000000000000000000..c59c052cc576d4c09d1d30d4caecd3283f6b54e8 GIT binary patch literal 2324 zcmV+v3G4QWP)M*Sc(Dve>i{Vm{|UfuWQ~h zv0Q`R-vcwT{2gD{yklay2ED%rW@7m}zOH!}Q(~2tmJ*BiFmQuiF(sB84#aRw?ka`| zNFi26Mh3CCk3kH1;J5jOSl)<(@2p^A`7Wg1aEOWJjX3zu z3QrTucW7(m5EDzIV5@YMiKP;4jmBbPX%uXgt}?MyqOH+bOe~Fptttkrd74I zL=L2?vXcHia>S92lm27g)^wCJp%9QptTSiM5G2%~p~`yn=t0w_O`~z+#)YbOz3lbt z*N*&g-C&mukVdS`%uKp-=Z`SifJuV24XRaF)3-o4wYs{vQ<=#bfN-n@Bce$Spgl%Jn3&aYg#LRYU|75P)APSM)6Yek+9Y7lUpScQd! zR2Y%?;6ru8SOLx&DR)wnOKsHmXNpFfK-T%kZR$occGQsR2WJ+b?Ric z;f!1ad_%1M{rgjk7A*u?7cN|&wr$&*f+t6ui%LEyK|m_83JMBn$dDloq`bUbIQQw( zCz>;7ju6}sA0YNZP!3QM>NF!Et zbv0Fo5n~p+Wy_Xf+pJiz!UO;>v(Z;xUY=Q&mX>DfnuWm(UJ1Q<^M>~C-!E?9PnH8qvOi4|WbZLE688@k9_1vBrMbq5hAPo8Y8 ziaH-|tf)KmxuP^;`5ZSQb}%9WOst6TaAmXzFtMUVj`boUz{H9O4_8Ku023=(Trw9l>}*)QckiC~7Q>e>UufmZmBPxyhYyPshY=%2Afbf|7YZWb=L_5NQ8;VXEF_JR z8#itc_AEB46HSy|Cl)?m1hIoRzS&?r5E$QMz>_3&aaFr@>lU)KVZ#Qo92rZJmo8mO z_`7}kHsOkfd^Ga#;X@iWY?x5BZk!bbd}x4x>%?kcQG|lNefyfV&6_tj^T<4Y{1}Ol zq@*Nj(xi!y&Ye4pG904a3H|crOG-~qM;GFLu!z5rC9sHeNQhJ!Pj$FD8TQVfKc7Mg zrZtQG1K;+97-e{pvz{B}e@d}4$JpY9=?0_y_wV1uS7(fSF{9j03Q`(R zlbFCQD=Q;I?C9&`s;d literal 0 HcmV?d00001 diff --git a/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-line manual text/007.png b/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-line manual text/007.png new file mode 100644 index 0000000000000000000000000000000000000000..fe0039fd7d0eef8f46b71834d7f5d4ee1fc484de GIT binary patch literal 2301 zcmVHgz z-Umkf5w9p11x+wP4C)Ur6mR{Z+4%~)w(U()OP5GX_LGSv8E{>?V`AwNX~}*vu_OboOLt5xT_P>nPbQXR!2jz`?Hm(J?f53l zJ0@1bpjW$NCYIXqO_+B~tb{?YcE?OCwd0#G@8U|V^73+G@g4?=U{_p;l?Vso*e5p? zM+96UR#sLPu_PXYIP%1CQ?3xp@hI3pIRuzk%7xS$4l%L35eMZ)!NgK7q~36diRFzr zC^rgE6H7U?HFAiFrBSeDy2`|oiMB?4F|jlXwoF%%?C0VY=C@MG%;2>63ofdG*XLhAf^pI9K2I(SF-L?K_jPb?!8b#+7zq^7!> z{yBWuk&Tn~t`t5>fa`6G33ap=r9 z#LCXjrrWn~+jcoQIa%m->C#1{UOD{y`IGYU@+dVmmEcoJNr_j6W*tYP^S&XLQ%}Eq z`9d`{HMDEjE~loh>2KV)k(xGbN_+S2b*&GtG^`x49zJ|XhJ642JuJ@m?%gAZQF%Cc zFLw0kQNq>4D6^(--@c_IM~={e0|)5wVM_ zuOC@u&z?OEVlr#iEE+Ii04-a#EI5l1plQ>lQEqOoxSBkD`ZPp;)v8t0zI}U|GGz+E zu4m7lG;!j@5FNWG?b@}Y($Z3zK7G1f6W{77N36w*7Zb!zM7C|)Mq|c|p@Rnxn(A}s z&NcJ9ckiac!a{L=`SNADa^;H1pFDYz)~s10@_Y!7fO5ntE-t3xp!Dk1E4;^+(jh~J z(1i;ZXvT~gbn)UvQFG(Q4Uyuoa^*@SVt4nbQKM+&$dOc5Rwl}}Z{IFbK7>a=Ibwx( zGkmG_C#ge+4noPeFRQAmqEDYbi85TFKr+a=bLT|a*RNmc<;#~M&xeKxXicp5@835> z)CKAx%H+R~a>(Sp^wb!)R4Ple&6Q>RX*4rf9UU}A;h zVV?Ex-=A8vXd%wepFdA++qN|YPmVYjm3#<`fUk&ER8&NR2M=x_m6eskxsM+|((Ku@ zh2S0ug?H}U5$9XCZpEo1S-WeaMx;XKYl!6UMvs@2!e?UK7>WUH^i!~t)(?*B zR7J2l=6_6cU<|k##@vX94Shqb^z?L!BvyQ#=veKLH*Ar&3TECh>kcALnl#B;6>~mP zUoki6F+<-F%VW3^@qnTtz{H9Q4_C&D023=#5hq|OQa?HsVuRwv$Mr3pi7r7Nru#3 zcOO1{FjuJBv+$ReZ-~{WPaoU6W3YS%FPfh{dnVSudnqKRPMxB(v^2s(LcBD`jv{PV z^U@gOF_xrwG$Nx7thvIrc{_a}U^{l~AiOX>cI=oP6Dc|v%Y+FN==t;Kgl%LX7f1zi zNg05|PJBZwrB=G7m_uwlb!^ytyFWy==AVm|ydb?eqmY+$Xgi}ReE9O~M&D|PJHQS3u}^5hAM z9SgRWVO8qn$&*dnIdkTid6hEC5$o*Pvt*TU!NNTlHlUt3ae_8&+C=Z(y(6q+#|Bme z-mv-cNOji0b)&6*Lees~z@-n~0jS63Tqj?xVqHiYP7!Cy#$A}2|T_+h$%kAXn! zY*@W}_pbOB!{^VRX~l{aqK!j`4v7?p;lqa`p#=*T2qNL<3)}KhICJJqB#n~m*RK=y zEHEFuuotCrQ}is&@0{O=M~P`t@QtGL|GSS+a!ick9+I z!W9kqSmeQj2Q+l(P+@A_I4cSiI4DOfCo$>Ux38(*ym@mokIbV-kB|sSNlBq5O_~Vl z+_|$T!y(q4&@W!Rpv=rnG$HN>OBDP@R+8|J4-F8&)yXKFH*X$=6HIFuI(AdEYmgID z514PWYwIUHCRT)K89$MzUb=Lt*!G1dOslkM(+1U!#JKmv&!95*S78wF9kH%myGHfJ z>KDH42{Fp>BxgN0%KtCL&KzTl7p5Bw{cqpCiLcHW_hLr5ofM=ro+dGYTTxL#hS;(9 zla6nQRTo6Hu&9Xs4nC>Xm*AWL8IqEkN|4H`DuPW;Zmu{t>XF_L-Z-PK08fH}fUwQT z%oO@4hYqAH_|%5;iu?D44$A&WPNveUS1Bz$-7G`79m&hjH~aYWufNDDq}ZSLiG@#a zD+YQ*cNFr~`@}*N0JshUUlkEoh*e%*F1~`tX<~6mBu8B#Rw5nZzP*7!T#1#Hl|?LG zz#w!CiJ)3siIoTkxKD2)z{K)qTzo$&CYJ9*=uHQhSl*0_??=VN@_h)s=>QYUn{n~| zsF+xCLa1FYOf2m}F2`XemK>b5>xGG>UC8A)%*2v|vv$2Ov9t@h9EX`$a&XqJ7j229 zU8E&D%*2umxGvo>v2=;FWIvf$k^$GHJ0_Mck(TV|zW@LL|Nlkx!H56=00v1!K~w_( XD>DS?TE>S!00000NkvXXu0mjf!NXCs literal 0 HcmV?d00001 diff --git a/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-line manual text/008.png b/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-line manual text/008.png new file mode 100644 index 0000000000000000000000000000000000000000..602f16efcf55d2b7b5e77528a2ee8a7480d921f6 GIT binary patch literal 2303 zcmZuzdpy%^8`p|a#H=Bwgo%cl!;mzw(HuiG$27+hG8r|egf`6C9CCQdS&L{yPdYG3 zBBv&YmN^ThrI6G6d;WZ%_xy-uJ~qNqtn8gno>|5BR&O1lJM4jfhj)vgH3%_sZyzAyTTiuzy> z+Dq;wbLOJ7!s($Z)v3Eud%$An6nDJ#$r{H}XZWg4D?^~k&J%(lD|K_vnX~!$_j}C6 zJP8J%hhT8dX}AjA`;{ZIlVuMd$lUfO-~`-Yp;qdSiW(!f{{wBM8N}q~;dfH&0yxNe zw>z9KiTVF;HDbZw@rZ;U@&ZARyPXIv+*lgwrm76&dOY6Q*_kzYMgYZQ@y@4;b@SAl z?|cWm+0ZQGT&GZHX!`3x5?1P85{6_BBI}<+z+ZaW23b70(ftN2mc8mHEq5P67smCd ztCe~}oW`J|3f+ncYv}kdh)pRMJGpcn;|9oY?QlP27h&RZN;vrT*FZ3yY;5UtI^N05ta;xN*f4E{oChfVWnyg z6@7U~ulJ;knl;{xrR9#kk%132r>MyKY0zgaS}>8=(6>BPo%Oagb8G#_9UhOTs;iqb z;ef+YExA( zJy&?kV^7mZ!@qOj6n2Daga&E)Eu?J(^H6&>nDhL8`(4q&o$bwq{!;TdDwZ=hf6n%= zaNC9as*8=tE?*a!T2RFPwx~a=M@yGDt($f7+J3!dfc@Y=x#b;OBuFRh>xFkcJuttl zW46UZgO^%}@W#L~x{7VF)%g*%ervvl-r3N{#yCscRSm%^#ksFF`wl%T{r+vxA68>&&01W-tzMw^z7( z0Ygi{+KlrzH-86)Joo9Akafw_4PXmKxs6Y8XMwQP(C88YVJQU7QBL(*gmKTa3=}RHWRn5nyH+ld02YV)9(4Gga~0#ZW&~3`1pG zt;Vsf0CDY}-jpt=DQc6{c3`U1Jgs0s5ep7IjE+7HC@JMJO?VnqZLFyVrFdeT3D{T4 z-h*uk2ZSu$Tmbque5SpVGb9N<`6Qd1K*5Ki`#Mg%r*v)E6e_cWq0}OpoFP3WF5#w# zk@Tq>sx)ccBvdfR3jh9YNrk5SGl~d9BrK#bE6cRVFuVeI%H$|Y+7Yx_qt9!$7Q>=Q z=Sn6g{P)txt&tuOpu#_Xy@f|h~C%t&O;I>lLZs%lPB>0+U*tX`O#mFl*0&H20$8nQ{-fPFxiB8 zYP{=kTH_yo1Jdk7fKFfgl)!X4p!%e`UjJc2l{P}5oTNtuH^X4CGU^DjaKM3%4?BxF zkeoFCa-zt^ROk#;!P?&5LCD_O5j&ribq|r+NSa=YlkD|JyejfkQ268P?Ylo}$P{cZ z@jReCq@$H=0uI|%KIX8HgUF^A*ACz3q)gAuEuwaohtpC_G2qe9o4%3OIuAiyI9TNn7C-S(0n}@iXz98^HJ8)2zJ3$iov& zstRE$bmGz6sgqF4gT3#ZFB8-xi3SI?>!&9e|AOr3_;*;BhW3ByG*h_w_-r1&2IJCD p@)D?Cc6SAQL)L+bz(4iNUcRpt{0pR2>`h>Y4~MnE)S7t4{Rf?cPt*Va literal 0 HcmV?d00001 diff --git a/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-line manual text/metadata.json b/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-line manual text/metadata.json new file mode 100644 index 0000000000..5147b8612d --- /dev/null +++ b/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-line manual text/metadata.json @@ -0,0 +1,3 @@ +{ + "numScreenshots": 9 +} \ No newline at end of file diff --git a/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-lines and wrap char/000.png b/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-lines and wrap char/000.png new file mode 100644 index 0000000000000000000000000000000000000000..6423e4d84d6f551aa739fc72cc75ff6b928d6627 GIT binary patch literal 3583 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 literal 0 HcmV?d00001 diff --git a/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-lines and wrap char/003.png b/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-lines and wrap char/003.png new file mode 100644 index 0000000000000000000000000000000000000000..9d0b130b1982bbc561f118eb2ee9fda4b30c3827 GIT binary patch literal 3632 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 literal 0 HcmV?d00001 diff --git a/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-lines and wrap char/005.png b/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-lines and wrap char/005.png new file mode 100644 index 0000000000000000000000000000000000000000..3aaa5d29093e809862fb7595f78d6e14f83d3117 GIT binary patch literal 3625 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 literal 0 HcmV?d00001 diff --git a/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-lines and wrap char/006.png b/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-lines and wrap char/006.png new file mode 100644 index 0000000000000000000000000000000000000000..0a56e9283c6ebffb3f508161ff03c4c6e57d358c GIT binary patch literal 3559 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 literal 0 HcmV?d00001 diff --git a/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-lines and wrap char/007.png b/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-lines and wrap char/007.png new file mode 100644 index 0000000000000000000000000000000000000000..a994b635f2462395fac1289f4923b3ec5dab40bb GIT binary patch literal 3636 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 literal 0 HcmV?d00001 diff --git a/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-lines and wrap char/metadata.json b/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-lines and wrap char/metadata.json new file mode 100644 index 0000000000..5147b8612d --- /dev/null +++ b/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-lines and wrap char/metadata.json @@ -0,0 +1,3 @@ +{ + "numScreenshots": 9 +} \ No newline at end of file diff --git a/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-lines and wrap word/000.png b/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-lines and wrap word/000.png new file mode 100644 index 0000000000000000000000000000000000000000..50e032251cb03bd00aa1ae617c7f6535643bb001 GIT binary patch literal 3592 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 literal 0 HcmV?d00001 diff --git a/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-lines and wrap word/001.png b/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-lines and wrap word/001.png new file mode 100644 index 0000000000000000000000000000000000000000..b1b77df3b7bac4f855906b66518c8ce8b14a9db2 GIT binary patch literal 3589 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 LNkvXXu0mjfXSnt2 literal 0 HcmV?d00001 diff --git a/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-lines and wrap word/002.png b/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-lines and wrap word/002.png new file mode 100644 index 0000000000000000000000000000000000000000..82828bc8b1d7ffa05815127eae267d9724a807ea GIT binary patch literal 3571 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 oUM8`foOV literal 0 HcmV?d00001 diff --git a/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-lines and wrap word/003.png b/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-lines and wrap word/003.png new file mode 100644 index 0000000000000000000000000000000000000000..b8cf7e1646a99f9277b512848b61d61804e92e93 GIT binary patch literal 3526 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 A)&Kwi literal 0 HcmV?d00001 diff --git a/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-lines and wrap word/004.png b/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-lines and wrap word/004.png new file mode 100644 index 0000000000000000000000000000000000000000..d84ea1c64ebd10c4e8103ffdb7efa7f18172ad65 GIT binary patch literal 3527 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 literal 0 HcmV?d00001 diff --git a/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-lines and wrap word/005.png b/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-lines and wrap word/005.png new file mode 100644 index 0000000000000000000000000000000000000000..c0f307805c2437ca43d0cf6e2cacd3bb0207e9a8 GIT binary patch literal 3523 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 FzW`U+%##2B literal 0 HcmV?d00001 diff --git a/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-lines and wrap word/006.png b/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-lines and wrap word/006.png new file mode 100644 index 0000000000000000000000000000000000000000..c054f24ae7437b86890501368b8b9b1dc89356e2 GIT binary patch literal 3530 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 literal 0 HcmV?d00001 diff --git a/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-lines and wrap word/007.png b/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-lines and wrap word/007.png new file mode 100644 index 0000000000000000000000000000000000000000..a0de90bc28d0da691cfb909ce75cbfaa7ca67b49 GIT binary patch literal 3538 zcmV;@4K4DCP)=2Mrit#X+eiN)ZDrgMzqd zzyK>QYBiA>U>OX=MFR#{aZ#%aBegk823R&L+$+f$V0opcZ7!z)mdy(HN^%BRUa4uD z%V~gRv%FPX|t*D?@?hL;*4|O%qiHY9KDa%916E7;;_*8Hkgp zqlyc#qRxeRAn7zW%^#V7a3nk}ekpSV>o2l#}U?rWlJyakA zEDu$-okcakvQyh0O3MJtLzQi3Q4O%{)V7DxGQjdsW!qU)11vkW?V+@y1D1yrV~UzS z4H#ghPk-j=J7~Z@VEKF^hT^LOanGMUzyeV2DYMCm4H~cqSh^s$ZiyrzjT$*pf+(@oDy}$}n@N(tKN$1X;!$`O{Xw8~6Qlv+u_rLyskj1IqeqWc1=+D4D_uY4L>eMM&uwa4WrROqm;6VBQ z`|qV`)28zB%P-44_uL~t{`jMcl2)-@yLQSyd-iOZGiQ$SPnA+ON$mQ@Mbu@@WKo7^Upu4c)NG+u3RqX&YcrlVEljo{Wp%QDaGmB z4Ie&SW#RYVe^=uaZn|~rhLbKTRH%^f#=X?48+&4VMFD|YWt5r{t(7I z$inQ}wJVOIx88b7rcRwId-v{@f&~i-agG=$g{}y2p|B}arr@ND+P80?>iJr=YN`9JTek{tKR2{cfDF>( zu!|Qj3f&RmQ1xRI_HylJn=!tMH7xeER99^1uTRs3@mTpDxcl z^GtyD?AfFI1Tw9^cI_Ho>DjZVyz|aG^3X#M$w>d_8^w(Crt4}(7meK+Ep)+UBXqH4OrypSLO#AEBt*gBW;W`E^S|etKZQ8U+ zqbOF(2}34LU>F814$ndN-+zA?Nm1R~v17*+AQ-^2FnjanO||x=A0%)4_U#jvL|DgS z5Dmz7?%b)ygj%jsr%q}~f^>taPoF+oOd^$|OXeZnW#iA6FCR|AkYm7N(Gj|nrU--X z6n-Z@&p>wyxAIwer<{sYk(Cu z)y5X_+_`gAH=QR>9^rfEEjC8R7L}t<8DKf8{?Lgtv6wJng4&r8TEhKY11$H~Q9|a> z04pJ@&Hb4+z;b^bC1ef_uoAM`+@I+r0+#!g?WG(VV0o#wZ7iz+mW|5xQc|`8i`8T+ z@%h3_%Jx!OZ3Pw|aUs5gL5Z$twm2_cy43b!IF)WR1+^7e>?Pl_WlJ>qbFD04;P?FS!w>S;Uw=u54jq&c2HRWMVW~~0{33zhx^?U1n{U36l#~>C{`u#nb?es3 zncq>hsS(7il`CpT zDhGw!ci(;T#1l^l?SQ2TvO4tRL6c^+n zRTg+NVeqC?ISlsMJ_7I8Uw_3(MVQrd?gEAv4H`61Vb}pGkN;A=dUgL*1hXwHn6b20 z4F6^S{{5=5{oaUwD_5>m2C%e1JVfgPIH5oP{1cWUO!M(nu3T9F684O`HMMSUX3rJC zBfLkC9#XAZHMNELi!Z(qt6aErNylxJ;-(C7ew1#NdiClR$QoOfxxtcW(V|6o1B8LX zyYIfM?r8z=02o||ZN#{=6u8*tv2zqZfW(c4=$awS#&{0XeeNOdAzD*CTWsFExzM#k zHEY%s{+2CUCcHHGAzh{#HEO86Snz1wa5WJ>6fRvY>ju|Vz|yD0U~J0x$EqN@LhgC9 zaW!G(qy2O%+OJ)@LHt-d({iu}9ivsc7<~{4;nC97t5?+{Ry#$-@L#a*_B-mwm=5qk z)`31VWF_FgF#fZS^_XdD@cV;P{lcZ2O3>jOR{@K+8hwP?HF@v7_YxE}VFdfgBaa9N zjc{rVT@#V$gFx^{OV<1WnBeDiIp9~QP(eZwOBIA`VhpAI+O|^PYxi#HYB@K!t^(Fa zAAO{Ba`fm?WxT3xZeQ33ju&v?MoJ9uFI>2wW`Uf0!1#;t81{>i#t}}AF<@}G?waA( zu3cL&Eej^PWWpf^_y8Y2G=Khl8FJf7ocmA|D&0_Ww(vqEVk@wiky+nn-gbBpdE?k2 z-CgMM2z=}Sae2#s`spXU5fd{;ggGON0X5dmoP!YDr6Ln4`FuXn7CQ{8IqLz#SC$8e zgKmtn;Aawo3jqdBwy}-@;b|FWxR}%<%fOv6C4NmlU+8poxSL#CfyHiG3N2B!EKw=gK?oGd^95Aa$C z+VCPJ#%P$mV^sRsV~+_-A$kT%92^@!m&=+IFAoc;%uHF(Wmbp`YisbxGp!Xoh;bpj zPd@o1%`umu(&ZVqk*&aDEh!YS0&P_ctLVJhd~9Xq8UI&bePtC<3MVd%O?3drD8hr_ z(ZaEV0)tPwDY{oo)j3xIU?Kx>abqFY#W0&^c>%L6Kw^!DNdqkxGeADY06bb6Edwyg zE0ifyCe0Rr3J)URfq>hd;B;@ZUFF0DSiFf=3Y6I20{NGt#V*QUpn%*Gk;xq77L@jZ zg9lYu<}6vnuKdI;9r^Fis&oARahJXf66@JokhFmglP6o&p%Iztz0hT>__FO8S1(xS(+U5cqVA-s2uOw%H z<&~PYxts=AHY?mK$r)gIrKW8z=f3~|0RR62)+t#4000I_L_t&o0MyGe2l2Z7(*OVf M07*qoM6N<$f?$ieTmS$7 literal 0 HcmV?d00001 diff --git a/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-lines and wrap word/008.png b/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with multi-lines and wrap word/008.png new file mode 100644 index 0000000000000000000000000000000000000000..7cc71eff769d8bcce44254f19efefedc82454b4d GIT binary patch literal 3532 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 literal 0 HcmV?d00001 diff --git a/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with single line/001.png b/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with single line/001.png new file mode 100644 index 0000000000000000000000000000000000000000..db63b2bf385b6ab0cba1fbb4ec34761ca8909c9b GIT binary patch literal 7284 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 F{|B>l^+*5! literal 0 HcmV?d00001 diff --git a/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with single line/003.png b/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with single line/003.png new file mode 100644 index 0000000000000000000000000000000000000000..c2e2aae979b49990ec47535eec7c66216b6ed10f GIT binary patch literal 6337 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 literal 0 HcmV?d00001 diff --git a/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with single line/004.png b/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with single line/004.png new file mode 100644 index 0000000000000000000000000000000000000000..548d6e5d00f8510e8813d848a4d58f27a00518e8 GIT binary patch literal 7209 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�}$@f{n#GJk&fEnHFJ*75di?@ z0KfndA{N$y8qKU05%{k&`wrzVR%A5fQ#ZeRKQu^? zAl-M3lddlnqR@#Ez!Nsp0R!~UEWq>5>3-z@`ucb<{K4#)msMFLHK&m>(8NsV^;D|x zD!{KRl)Nt#{qq5?2qmZk2SEVW1oa4+Ib-=>{6mI`^bj zF4jN?tcyolCD{Mt^B@X1{Nv0FH1>i2f8>0ykKu=RFeN%u1*Moy890t}v{APdd2v`J zK;UqXN}JmN4=We+Nf7_>VN!6bn`g`#1Gs6za6vHNnRQ9w`?ysY*}uNeA-YuH+E(OzbcvqtYp+XPHO2DKD zS3z}OrMrp&9^Zt0H_zlDAeb=3HP0)efGGpkkD@DnlJ(t2=fPE^MK5Ix<64Kh*7xGr z;oT8PyGcB5#ROa{rQIzBLQ)G|#ou>Fu=Wx9ksYvO2fAGj3Hz%$t3X^8gY|@~NH}!x z4u?4fyALcL{Cie%3V0whytNP%xxAsA9f!C`2`7O8sa**h=b+>aN3DloUV(7;UKTt%-Nk_ip{c5;Qg+3L@IO88qLKgr literal 0 HcmV?d00001 diff --git a/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with single line/005.png b/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with single line/005.png new file mode 100644 index 0000000000000000000000000000000000000000..f1d04d1f92c67fefc91b43a649629164d4a9529d GIT binary patch literal 5271 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 G(*FQ#;{t#H literal 0 HcmV?d00001 diff --git a/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with single line/006.png b/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with single line/006.png new file mode 100644 index 0000000000000000000000000000000000000000..f74f5d443a105c295a9b2b0e5f0751f5087984ac GIT binary patch literal 6323 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 literal 0 HcmV?d00001 diff --git a/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with single line/007.png b/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with single line/007.png new file mode 100644 index 0000000000000000000000000000000000000000..5bdb47a2617ffecf6d6a5fdf382ec82b3f8df455 GIT binary patch literal 7307 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 literal 0 HcmV?d00001 diff --git a/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with single line/008.png b/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with single line/008.png new file mode 100644 index 0000000000000000000000000000000000000000..5736881e2e44eed1e52f6b134aa4d73397a80b58 GIT binary patch literal 5229 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 literal 0 HcmV?d00001 diff --git a/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with single line/metadata.json b/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with single line/metadata.json new file mode 100644 index 0000000000..5147b8612d --- /dev/null +++ b/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with single line/metadata.json @@ -0,0 +1,3 @@ +{ + "numScreenshots": 9 +} \ No newline at end of file diff --git a/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with single word/000.png b/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with single word/000.png new file mode 100644 index 0000000000000000000000000000000000000000..af308bd7e6aa34ac6d5498e8443117bfec461dad GIT binary patch literal 6527 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 literal 0 HcmV?d00001 diff --git a/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with single word/001.png b/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with single word/001.png new file mode 100644 index 0000000000000000000000000000000000000000..5bfb2d71e54dd54a95cb4dd2f1ea3339bc32c7ef GIT binary patch literal 6996 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 Q06U1=iW&-qH%;;X1^TyXlmGw# literal 0 HcmV?d00001 diff --git a/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with single word/002.png b/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with single word/002.png new file mode 100644 index 0000000000000000000000000000000000000000..609491054220428d2b22674d0512c7876779b433 GIT binary patch literal 4957 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 literal 0 HcmV?d00001 diff --git a/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with single word/003.png b/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with single word/003.png new file mode 100644 index 0000000000000000000000000000000000000000..09291b10b02be95773c3add76600abedcd0db272 GIT binary patch literal 6522 zcmY*ec{mi_*Pj_9V;^hw#+IGz*)q0~M3G&V$R1@4DN9t=_zGE*tpy=M_EE`_CHt<8 zkS%NW_xQc<^XvQmG0$_KGk4BC=X1`v_ngl}Uewp7reLE$AQ03#XEhBG2&CxI2XhQ0 z0k`9P5eO_oM^nwnAGwfuGlt2Sy``Jqk5||)7PWcLj**fljEUMWH@ob^y{L0CW~T)G z?4oTo%0hUC-DLdAY$eKUX~PbZQrGtf|`F zg-709J{C>K(KtkC26p!9pD+X(7tesgnCPP-(y<{Sk{FZ7JCw7+=v93LvHceM_X!j~ zPl#=j;II!9DnKB_c3LIu4=qw2MXIM!O2KAULmJ9Mk1vg4G2TIkhQaEuY&_>28yaWJ zk3@I0mwsVH{`&iI8Yy`eCjOl~-JZQ-01r)`9_^_b1!Qg3mKYyCgUOIf~x#O!visx&w z4*>6|E-WP|I0p(I;rpmSK_()MDU+KS4BJH0E2)>#YeZiG8$96Lam_L^B!&+#Jx3*W zj~fwM4PJOM#Cp(zju0|HorJ0lR=3eu(OtVR?SBc+-bJ{FgFO-7wOl}(257st;=+c) zVzd!NijUE~V8lJNmzWu=SMJdU64-F8$Aun+b&f@Z4Y0|(sv%IU0EtwHexevQn*x30 zvoN{=tOx?}DxE-GIO4AmnU3+_4YLg>=O#m8OJfjW4(9k9uv|GA^8bZ7wRcG|j46mP z&lq_qpS3P!7yR zdjx6_8iG)V!;#+BnFn zXov?h8QBmnUNm&iT)TAjxU}*;ybDP4sZ}`d;}77}?PyjmjqD2WBU$5u7C`L|mX5x6 zp#`W@0ct5lVaOd927GDnN&Q;^d?DS*fUhVy+5?Y3b%Lr+Rs$4Z8V;E1;BJt>&-8KB zMAs!!ey{+8#)a6DhG^1Z@J%#eBBy5)C7gkPdO+|+p#|_d7(N}ypMS(FA#!G*uI$@; z$lG{6*r8K1d=H;Meziv)EkZIYLyp8$K(4vQvcG|BqypPn@z0dkne<~c2>qC!ffUwvoUj=~VppdRb z9cutk{t9*IoIi+%4N%C=gE;jlTZ#I)5JC|^+vK?cD%8e?427!HrHvCSMcRCX>QCp!7jdb2YcI&pB_s- zttvg>HoFpOaE1<5(s)qwcCAaPv)c3fC)uIP_8zs1a%+2Q-RV#6>x<6y{@LaDJyu@sxJ^)5s{D29qM|c9v%G5`j@$T;KC*3@C~&gBiiSsqwArXyJ=Je^ ze``vf{aR`0i0_b9C#6JPPk+i>-?MZ&UczICWb>AcfSFXi4l*(9kA=mLCxWS58~`C;G{EmpbGx-B2j#-Acn zSkkc5NVd1mzw+}*a9aJ=*p9Fx9&9AjuGSrKpGtiQRtan5Y$X6 z&@wf5U|RcC(kLhP)AUO#??ahY_jLD47m>jd8`YnNzD*%Wcb9-kp?{V!CJ8w%o;I7W zti}Jly}3P|!mc8eq2OKcS>+WOmw(gS-wnfd(c8w-5)FTHuHT9Oq_Q>MSGV4)>qrbE z?b=)@?@l{&dk6jETayL&~#h+9eh zr}vQznbOY_FPs^9b5Qn?%gt{xz-dUCJ{qnCVaLnG^GQ?AGp07T2Sn>uni~$R1E!N4 zd-HS_+kKWNCs@k|#1rl|Ln5SwKVFzc^PTfBQS_a^Ds#{-RwAj#`}x=P;aeluiavUl z!Da9v>X~nSsN0_XBOE!>KysWf^R8VKoA zL2I$nYuR!kriW$~b5iwX*U~;>OwuZJhX<#E4;M#%z4iXd7CZG@v?u^v;@r+@1RWd4X*O%Z(F#rshC8x>_wMIP}k z8GB~ve7QX(@e*H~<|we~)v_Pcrdxjz-chSmsz*x`n(HGH%Ki~8^-ZJ%EK1-f;(!X|Ez97?Y)P>;m zs#|&OyuHboXCcX*F)Gb*wNf5bUZ6KB?cR4|*WuDDGsRVttL@^KcLbgU*;S6Z7GB)n zfpX4}&N`JeXl76CwCev@QLX*XpRf1TqWDdY{im~VL;JKDjNRPrH%}B-8DEV`_Ji7~ z=;xof^fGw%i__I-nSN3>cee9w5|Y<^+60mABl!1G7!%D9qwD&3`=Yd@-rF3R0kd4j z<*&6mhJrrm$&I`ht(nbfuUZWcQi_$%qI!D<8^Sw5uC&=2p_vE#~A8&R^i~F{VQV!u7$-j z%D&3Wb!#z%O}ClQu1%Sm#^Q_b;<6|j_BY46SWc@jV&6SfJs7@LIpRpo->0Nm+G}q$ zpWLPJGoz``%BQa?`0$|XQ`m-bS@l>uYTwkvvS$de%jrlH#c=>*WKQ$0MfPKPsc#@V8f{WLlx9bi^4mu z7W(;yl7V4N)j89o7gRQkoaPf!(tl>%dS#lt4F#{fCL`J`b}1#j=8<>DrL->B+MCO- zCTP-`%@^VyUqOW>aqsQPP1O9PRauXJyE9*?!QR8dtrtERyx)@~R@k#(-QO-!a*a>Q z!$bG|L~l|-=*yjJ<9qL4nhX}IG)k9SOvtFx70I~tC2Qb+@y;3AkGWU*#@#*m1GjW0 zH`3yqf-~n>``n}f_Q{~R6X%fKos`lv%_Wnz>nk3`bwh|r$2DcQjlLa#j7Lvt5hL2T zx7yWvs%AOIm~GTSPNAnigIa#PR7Xgu{O&99r*qOCDl4@*OR{H8J2t9EUS8RXZa#6kQ$BMo$%Uf<;WDKl$^~_4GW6aPaL5rFEM^wQyTG-6= ztnO+*XXWCZow;W%y75M5FZtCmt1k?dJc%+Z=QbM*&l!(%W@`hG9U|wM6=RQ@-VS`+wZ72cnzZqFYPrq4__Fz#`&6uUGUthfjWz3MpgKC4 zOfO%VyuD>rV%J44&o;7Tj?1M&i$m!! z8;DiCOgddJY%1I~(Yoo{H{%oQnb>;&w?Xu#OV%_z$;S&U<{jvY`V&&hiRE329RWMf zm?SK@zsPfB50EZd{C-ywCl+A+y{sbYZnE8AJEet(#n$WfN50T`5K;yLg`IUz)a~I* z>ssTv*pbE4KA*LYD{R)aP$;CHQT6ej5K-zFAgK##7oKq2o`u$A)Y1Oxl#htl>TvL^N)98v_Lh0z&FVIEWn8ePh-rRV%X8CaH&9S%Ejrlq~wF|GK z%fCH8c))iOTAcZIZ{)mZKFxQ} zOa->h!OP{66|}08EBn|dwYMnQ#ND})CQzCk9)Pg>o|`Vs;pC13#-_1-SNSY4CVW9g z9)xVSp;v@0!g@|bOeT0)G`-zF@n>i5Bt8 zW4Xqc-Jw$NtVX#dJM+e?$e7LNdSOzHR!Q#0mu2W0hHGDRxMm_hdYsNvLyPd8imKAb z>+=43@rYntWv3-Syb_bm{HakDpSd*$oer)*abnZ+-tGiT@ui%|JE>M{+&e5*wJy_#J$fRT(e$|ND)NBTfjYGk zUlp+f&!#=CXaFn30`$e!-ZoXgI+RiyEPiZ*`Vb z6$@5&?!h^+KEZLhWG}6JeHVK&>sRmR%9Q6XX)(!yrE1wB=!(7HkAyCLTA%AHH1(m3 z>1tTN$K5I8zy4V})}!S@x?Cp=Z138J@K*I#eHY&hRVEV*&>a>{r;i~mF|!9ICD!#B z8!a;ZYS99lOA}*9tvU1Y^qo*!SuIVNuA7))^cPELqJ?4oi2e}bp%^A0g4=bW#lOBV zr-1eX)~DU7^#%)S8b;qF`OS3>)Fy-T3YO@KXy%52R{{p!$>A@oF!17G3jViHbBl@+ zE64(BZISs(SV1XRwSLaO{#WL}e=+V3ui5ki>H2UyHw*$17~aefyaKHL%Jf;bkx0ys zP?*Q>3x4GVt`H_V4yrw10aoG2c8y0XlKQCOc1f75KN8>hVjdk4F_jPcY%o6_!;ijy z`KpL+C*X=~lVWN2ffW>mdD5Jb>T)Ooy$6%fK~U>o;n-#32@F6$d@aWF2RH|R81t1s z$Aba?6^4ppe&p$)MFc{d^#DN@bo3MoW^{R=4#vYNw7HKcl(;G6HDDHA9L=7@^Sr<; zaKP861?_-^IU4~+Q8=;*(I5l=0R~h=cc+3Hi3ta*BAT_1C_G&l_mkIM*nxD@!Z{Er zrr9GvE2aWyxi{~KNI+d0P@kFo!Vahd4>mwUMkYz$#E7;7j`Kqt;}IEU2+nCc_i2#g^8t|^3huYnLrfIuaz7_fpp_fg=Cd4O=?`I;*Qicf|H zk(;Dlb*2Ow3hZ>SI(q{H)E;;mkigCPS2$~f+6%`YF>%@_^8mMGKwXrY$^)oGPKXd^ zYCH;w$_coT9el4%Y&OO`0;G&Lrw}`|p&WFANw(91;?)#L zOf^LDa&|l#asWo(*V5wt0LlLejSwiGr||%k`n@E5wd}{RvTI4QR)g(-W#goUk8kIj zLR7lI8wFb2l1BD<_+CmYy+IxK6IK~yr)qZ1LhyG0(CtX3EcNVlkoF$J9|EXtZs42* zO#CTApQF8OP7vE~W9AU(b`2=e3q^B6Xs-)U1pk#$aJiiWa)}i{k)lC#0y9p8Gl}K~ zz|giHm4he3c~T$c?;rpv)K&e33F-;BG_O!~ddRDP3vqNrFPHm#7>Gv#s7Ro*1Cs4* zG^qG@0*Ssw*3gDB!Umz{0wG9VHb9e2)fEmFf+!k>>s`XO%7-r0YXc>OS7v%oH6gaai-VTS?iFd#QP0mnfpC$YJ*L0aX2wjBDrGiXJ696vXt zin^^NB@*)J+Jk8zh2LGxQ>=8H6=480)e1bS5v-;Kp^># zewbsR@$rxLL?EyTb!7!TZ{%{u@zO!%vte7~ldE6qq9f}0yj1i`t*q!&$o&kLyUsK4 za~dq;MZCyWnkL>vl2duLM;M(dwPK|5+F3jMcKCiz_beCh+^dzAnoR4Z>Yk)`5}#bf zHAy?ikr;U#FFN(aUmOC7`SA#WPdKe=#e>FKp1{xa>dG6k$2>q`3?^ir9ANK0LEvX% z$`>x8H?$DV9d*awu%QCbl+2GU=?C6;P@z<~f-LF#3V&0e$hkrKmu|-=;R<+pj>Z<< zs3p$^M{+1kA{+e_4iS8VR=!kZfg$pGG(un|9K7?oS@EII@djB+1|WLCrf9TBWdfinu9$`uByCy{8rlQJEO2vj(jqWzGu)OLv#_4>4Y>CFeoWAX@;6|A*V zkEKCj)iS_-E!8CiYJ@XJ|0%Yp$oE(sVDJK9UTRN*5a_b&5Lba+DHRH9uZn1Pe9RSq z76Thm>_L`dVSJU~g&GGU;-Wr?|D&x{3<}_ZC$G~r%g1fg=23=@n|N0CHn=gSx7fvX@hVbmqfcRh9>0woBY*0`r z0&jy<5=G-;WWdOuhj^9$P{!fp&-MZ0KJVDGfH)Qqb39B9L!e)R@0aTw67U#Duo;y> z#JD{(EC_}LD38=f2(%+-kmX=o)J*_K2^pj#kgTcE=TX77;N&v;5Q5%NfOJ02f)KFw zJdie}Z*ovE4fJvq&d5vr6$HoxHf*}slgW^nFHoodQm%KuIEJB1Lg4kyCi^dA=%PWo zlGM-sFJ1Z6>ANM5|6jTEID^{Z{#Ir=Gwft^5~&K-qop!km2l4htF$k58vD2NNOY%e zd)<)BuIUNDEWEEKg!_gDC4~e@9YYra{!YmCQ(>+s;NmSM74TY6^AzYzn+cUO7^nkQ zzSKnRClwN7oe4?tVdQqkbt~Xn!2XG=$4SswRUlSLe}PPh+<;7!-oHeRisFuG96eRE zzjNX#`!{}YW;{%C$NYE(7UZ(5gwg3RI)lr;l;BGpnmCR}%MTLV4VF^-bg@u?R9GqM zKHABNbpDKi{^5*nI)RZVNI~(XIPV?h6q?nh^;9wviP7VYd1R?yjX#Hl=2ir}it-#( zP*7?JSA0K+9xP)(#Hd+QXa}WZ?YY26tjB9;gnT{_ORl^=0j2wwR)r@?;)*kEhZr5LGNxA~seR zfzqXjX*7owzOk=ZA%O27hH>V?enj{$?LC|kwVgZ}K-B`Umb&n`A4nCKxM3tHVtk!O9&OKhG?d2RpLP7de5UO2cB9EDn^OGgBQV@UMcB3 z#6bmdFgV5sLmZH~9K1|GHjEh~z!db&kX6Fv!P0c8ljZE+IG|iAMAF>DQ%$1=kT*U` zafq@A%2c+>z6@nHfrpCEe>vJovA-2ce_I9b+zH+jsp zj#^Hc>+bxE$Egked)H15TLk*ICVtGcK?!E6+87!|J?P$=44m9IBU=9%brQP&)pIzd zkk~N#dvUOnzizoK%I%%J!2U+3kZmtPAyz3Ed*v&AQcrd+yHZ$knp?w&^uce^Y+hD? z<9u-Mn>(tLyK;xycglBW)An*t0iX)*ZyP=X9s|0nDgIkuhfhbooy#06YS>+zefR70 zvw}hOo{quC%iS7Mc3#(WwQsUPQY#9(a+67IKp@v*q%`J=tD({nhqh zvg4^IS)`I3NVy5B!xi-tzDwkZY4<-%cE@Q5G9^E54W!uLnaj#mea1TU(@pSj@;oZd zwbmiKpu~g-o(Y!W6R*^tN48pb#9tUKmW;p7y{1rCjK8{C(~_wCkQ^CRGy$=-37zx# z5ljDeq*x+2H@6^9vp&tan5cJePnuFQ;LQhy%Z-P-PJA^c;wPo%arf8iw^E}dCTc3Z zlLT*fUG9;-Q}xwrZ0HI!2=Z!h{#ET(wcjX*w}9W{O)qY$Tr2MOW*MJAWt;QQERE6 zw&yhxk-yw0_Nve&l+og$uAJ5}DQXy*$3aBPPtspei(5mcZemmd6*1yU$2CKqFe@6a zKG)?;325dmlqj@V6ZTr0__|!yDL7GHr<;Avr-!t^F-)6hhxc23o7cBeHCx^d(c8YA zN&J<|ZvTi__LiXPY1t%7bX4urd%v9=N%aaS3i<#PA92gUhl6^MdI^%9b&!1sQe76ztp!c<9JmHjz zX6O@I^MIY1^O_a2gIB{-`DPhAc(+;ZcL`7UMbeTd`21`>UhjZJHA2SDQ0p&MS57gD zb3OdkBiA?rjo1A#mb3fn+Pmp`VFJaQ(>ynRUQ^p#wSKK9!f5XO0@~iKE78=|fx+1N z6shikoAwQ{X$yY3wrCd95c*FHr`jurOgiV^1fTrIM|{^m?m2wh_V;|>wdX>_x3kRJ z`5HNKyw+KPhdyP;W^2R@dpO^Ad6|h_=wleV{ouCgP1h+4-vw^%e8I;3jRJ9vyS7>W ze+qW?Mdoj-QDmCm*wi3KUj6>)^gF&`-c0Y8BCkCyh*lM=*3Q#&S%KnCMQ_6T5*A@* znL4B>MrbwozlVF~qtxwQ%J2oh`kd|9u1~NuF78o(zqi)tCO&?*MQ!Nt&zRc;y>`5! zhCpTf)`oYyh}A|ko1E;8s@7j>YICVhg4q{@D@6t04ADwf#^>DFK*vcvdwgJu8^tmor@S{~$JOM(UDUDhl{i?Cz`4=-FPj{*m)pGEDt=oMV?mUdF6U zbw8h|6}t3Jd-Q~fRqChl*SC1fry}@Li*DNp)c@`uI{Yl$er|T)mbMtPhxfPO<6SvP z=8gAh5YuTMluA!;vbK4EV7iP26#XH_Q2p673hU8aL5=UdoN zt_9}M4aene7-r*%^?y<&j%$UW{@I@JTT7`NHm?(La@?z1tu6Ev)h8M_ST&b_mg-D< zQl$GHw(3-3Wp;k3$E;CC`It~ z`_OIHIM#Cy6lD1dD2^Uytp^>ZLW}>d!BSF;vdKe99aiX)(z)6|f#ru9;6o%a?Ggw35-oCikAhvD-O^!-s*U zwdsE+{~m5xJhE}hO|cb8{qsGki$0Mp!TE#OqI86>*$!o*&cWf{+C)4;(|#a;H6)WI z^o?IAy}{3J29JdUXs7MO9cX<2=}yi{gauUc5A4e0Wht*GVX&L$y?1C|6L#i7X_rj43{;;=?9rT(%EZG2QbG z)wsp@9!c^Xas2cl!YlAVl2e%lYof`pH0TaDgD{Uw9$g|D@qm&IL~ z#Yi)*HZ$5XoAG@beF^KM!}C2Ie8i&eu25~cgNpCs4%d}i&b|mv#l&Bal6>X}9jGOD z;E`>2akqFV%{M?L&&1S6ZTN0|x0V1|F&|}jV?XApIrd~NzF*bG#WRP6m{fh%MM=Q$ zZB$kG?pX~&;&j1g;@zeoWSjWL;6GJiJ3d!N6f{!GmVeG$Q`i_m!` z&%S5A$VE2Mz`d6;-NZ+s6TM`dc+FUhId{x4it=+l$1gbr3#pPnc<8QcUic&a={2@8tJTaGt@)*!Wgi_os`E%||RW?MiQZ>d$tQ zj`saErxNoK$(i_D4@u6kCdM0KXI)FVPp@Hv^V{{WHut~u*!ub)OGuQ+|6>2+<&z|+G6JqjokjX zVm)Ky5^3$ksu0(e-=op8Q3e+i-VxX5mw5S#E8=u}_dHT%UyAi@mX-6wJL(y5*eV`A z$Bp87kYfdXzf?^nKa6!;2q^Cszr0jjohj}gvRkzI;o<2tPEY-uJB1HLb(~@syxvMm z8vb>NTZ7lmovBA;G}5JxS$sXAi3c1Rs=Ckk+&<`LDB}r(93kh!Bx^)(5PQsa$f>8t z7AI}fT;BEE(N84V)i~SqM-DUI!7KAcy1)jQB=N!n4$#*)yqb{5A6=L&UgHk_NmZrc ztY4(^d%vTi)j_~-rkvb=vn?@q8x9+jdy0Lobt|?dHB*%L*FP|(h0w@;x%viB7CxBa zqj}60P{Sl*t>&XASXC+IoKAQk6e6|Q;lp)IH~I+Q*W7$do!$cK!65~jix#b-0^UYF z0n{u~1N;r!Q=3I)C7uo}ZvIZCCOOR1GQ0EH-RtdaZWZZ7t!08p)r>#BBgWtyN;kT2 z$n-s7b*YkN?1_=i+n0F@BbvSGl_tA?KQLM(M~RQ-C%z1i@5B37kKfaHSJiRdslT|S zr1Rw$j~Mwc(**^V4hhF(#_q*f4GJzX-KgvwDM;A1?p=dkX z?mo6q;Mo64ooCrZLrg-sc^r$mZ)QxcLGwlI{b0S{)<+gN1i#g+S4?hmaS6RpC{4=M zEFUfOXe|AExOcWuSMYP$BKPsCSEqOT&*2D&00UWtO73 zI2I@VaQkjyy?{?gpx9IYFQry9x)W8c2W@BFM30V=Tbj+EGp_oPQPBR~AUs~0OTQmo zEfu!%eW^A4biLHUq1}?(__oRHGlv-ENrnOXB%Y2x%OfOHPK)=iHaElw*`~4rZ?c7` z{oy)rEz9iJl40VM=v{1*oz9LU8TALrXNQCM{Dw*vL(STV-PO9aNY=;+50YlCA<_Jr z(yH#+yB8W=R-edDoJt5;N%Qwf%gbqu^rcs)8Q=V>O7T3iec}48?2z3j%{*z&wDdGP zEWA2CWE{sH42zSejLT5*=P!{Dx8n6u>=Mw9z<}TUTn(VRD zZ!4ogR>P%OY}lqJ$*8#ZneX)a+6rLTPUo5>U6=18Wy+OWvCApZdy1ZrSE@X6lPu z!Sv3onf`NLZE@O^o3D3>rjZj)4MW@O)0zXGIsb^N>tcSy!M&a2LdzsDo42D1Z7W__ zQtOz{ET&s+(HL+|D068@xR6bkdXPy(0mlw}K!&_i*w)d7qQ-qXbxkYx>}~%AClQlI z|D!tr&2)P-ixba{kKJs`rOc+)N&*Z3>_Z+jgjc5z|;WEcdw=AH!Ug@ zh#()=AA#VA6_^>HpS0p&o)u`5mL?#WgQWrU#y4)~B3|TThcNV5`{x!9OwBcTzI*yN zBT5n)Yvzkj@=4?{Mwy;}c)QmE2ty?RdP0)y78tOE@Gi041SaJK(32uRB8yV+GT4vI zV9JTL8EB*?;9<*NvLjLdp?sx)<8|Tqzf(u_VE_S-hET#Sb7QBl1YQRqnfhO8k3oc7@En(!HBhDfKz!$@XH@|#H8d?lky=$O)6r%8 zEUY+tZW->~#t@jz$ki{$fI$HtmCemsEEGEQ=tUsX3$EV02y`klP-KpD#*3kde`|yM z&~b~x_yjtKQJ|;(0ix+2&Q~Z%$px@{^OeJK6!wWGqB$b!QULi8HT($Z)X-^VL0Ca{ zl(Z|z(}4djh@qKXrjmR*REwK=Y}K zRFDHF0E}ybWRfKojk5&9BOkFe|5o-kqPOMHqX232GW#(|H?XOE+0o7Xz~9LLqb76u zA8>6=@yM0BnKCLO64EgWt}A5Z_dv7<{Pud*5OzOgCTAshd*v1xIvr9YIqfJ#8N3g6 zH`bJV5lB0L_PHiCnjtu7$zQ(IOb#_KhrXu_bZTt*C3;i|Ec_o`(*;mkfD-!?4DHCX zQvxg=fRz@|d=XmXF-!zmXg&%{pb93A3RDq2tUp#Qj6%Kf@47KCpcF>opR!1FmM;4UH&~bD;gqhC&3%2LR@sb^1g_ zoM7;Hl4|L_HAN`|@&Ra9w^K6}aa)jx@w%9k@=Yv|2XBk#ry=n$Z3$&$_N*u@MjJTG zo``hFQwOY!XOaIk!WE3u@+?cdnD3Uig@!mfVED#s4rPs0InhLl{bbmE#^YzK&Mn`Q0YeUg0 z;?P$90C-on>8J;R7Utv1-yX?_VSAzhU8oVW0g(RzYppsYXc{bRLG($u8TQ^xgfKM+ zd;q}k55l-@0*5}BbwLco|I+>pgwI}o|1!xmd<5ax(P5ViA(W<4Z5P1m4H|^Hik5PT Iq9y)+0R?VDasU7T literal 0 HcmV?d00001 diff --git a/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with single word/005.png b/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with single word/005.png new file mode 100644 index 0000000000000000000000000000000000000000..8d5288bd132680b60b75826d3b55fd5c70296dea GIT binary patch literal 4971 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 literal 0 HcmV?d00001 diff --git a/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with single word/006.png b/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with single word/006.png new file mode 100644 index 0000000000000000000000000000000000000000..2f71661d84e403510ea30e56fcdf3532b0c57dfa GIT binary patch literal 6524 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 literal 0 HcmV?d00001 diff --git a/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with single word/008.png b/test/unit/visual/screenshots/WebGPU/Typography/textAlign/webgpu mode/all alignments with single word/008.png new file mode 100644 index 0000000000000000000000000000000000000000..d3b8189f30bc7b4bfcb5f8be82c0601f4586efb7 GIT binary patch literal 4976 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 literal 0 HcmV?d00001 diff --git a/test/unit/visual/screenshots/WebGPU/Typography/textWeight/can control variable fonts from files in WebGPU/metadata.json b/test/unit/visual/screenshots/WebGPU/Typography/textWeight/can control variable fonts from files in WebGPU/metadata.json new file mode 100644 index 0000000000..01dd8f26ca --- /dev/null +++ b/test/unit/visual/screenshots/WebGPU/Typography/textWeight/can control variable fonts from files in WebGPU/metadata.json @@ -0,0 +1,3 @@ +{ + "numScreenshots": 5 +} \ No newline at end of file