diff --git a/examples/assets/textures/terrain/Canyon-Diffuse.jpg b/examples/assets/textures/terrain/Canyon-Diffuse.jpg new file mode 100644 index 00000000000..40bd89938da Binary files /dev/null and b/examples/assets/textures/terrain/Canyon-Diffuse.jpg differ diff --git a/examples/assets/textures/terrain/Canyon-Height.jpg b/examples/assets/textures/terrain/Canyon-Height.jpg new file mode 100644 index 00000000000..8d18a73c891 Binary files /dev/null and b/examples/assets/textures/terrain/Canyon-Height.jpg differ diff --git a/examples/assets/textures/terrain/Canyon-textures.txt b/examples/assets/textures/terrain/Canyon-textures.txt new file mode 100644 index 00000000000..31950476480 --- /dev/null +++ b/examples/assets/textures/terrain/Canyon-textures.txt @@ -0,0 +1,6 @@ +Model Information: +* title: Canyon and River Height Map +* source: https://www.motionforgepictures.com/height-maps/ + +Model License: +* The heightmaps available from this page, are provided for free under a CCO 1.0 Universal creative commons license. diff --git a/examples/src/examples/graphics/multi-draw-instanced.example.mjs b/examples/src/examples/graphics/multi-draw-instanced.example.mjs new file mode 100644 index 00000000000..1cdc67aced9 --- /dev/null +++ b/examples/src/examples/graphics/multi-draw-instanced.example.mjs @@ -0,0 +1,190 @@ +// @config DESCRIPTION Multi-draw instanced rendering of multiple primitives in a single call. WebGPU-only: this rendering relies on per-draw baseInstance (or equivalent) which WebGL2 lacks (possible with shader workaround, not implemented here). +// @config WEBGL_DISABLED +import { deviceType, rootPath } from 'examples/utils'; +import * as pc from 'playcanvas'; + +const canvas = /** @type {HTMLCanvasElement} */ (document.getElementById('application-canvas')); +window.focus(); + +const assets = { + helipad: new pc.Asset( + 'helipad-env-atlas', + 'texture', + { url: `${rootPath}/static/assets/cubemaps/helipad-env-atlas.png` }, + { type: pc.TEXTURETYPE_RGBP, mipmaps: false } + ) +}; + +const gfxOptions = { + deviceTypes: [deviceType], + glslangUrl: `${rootPath}/static/lib/glslang/glslang.js`, + twgslUrl: `${rootPath}/static/lib/twgsl/twgsl.js` +}; + +const device = await pc.createGraphicsDevice(canvas, gfxOptions); +device.maxPixelRatio = Math.min(window.devicePixelRatio, 2); + +const createOptions = new pc.AppOptions(); +createOptions.graphicsDevice = device; +createOptions.componentSystems = [pc.RenderComponentSystem, pc.CameraComponentSystem]; +createOptions.resourceHandlers = [pc.TextureHandler]; + +const app = new pc.AppBase(canvas); +app.init(createOptions); + +// Set the canvas to fill the window and automatically change resolution to be the same as the canvas size +app.setCanvasFillMode(pc.FILLMODE_FILL_WINDOW); +app.setCanvasResolution(pc.RESOLUTION_AUTO); + +// Ensure canvas is resized when window changes size +const resize = () => app.resizeCanvas(); +window.addEventListener('resize', resize); +app.on('destroy', () => window.removeEventListener('resize', resize)); + +const assetListLoader = new pc.AssetListLoader(Object.values(assets), app.assets); +assetListLoader.load(() => { + app.start(); + + // setup skydome + app.scene.skyboxMip = 2; + app.scene.exposure = 0.3; + app.scene.envAtlas = assets.helipad.resource; + app.scene.ambientLight = new pc.Color(0.1, 0.1, 0.1); + + // camera + const camera = new pc.Entity(); + camera.addComponent('camera', { toneMapping: pc.TONEMAP_ACES }); + app.root.addChild(camera); + camera.translate(0, 0, 16); + + // material + const material = new pc.StandardMaterial(); + material.gloss = 0.6; + material.metalness = 0.7; + material.useMetalness = true; + material.update(); + + // build 3 primitive geometries (unit size) + const sphereGeom = new pc.SphereGeometry({ radius: 0.5, latitudeBands: 24, longitudeBands: 24 }); + const boxGeom = new pc.BoxGeometry(); + const cylGeom = new pc.CylinderGeometry({ radius: 0.5, height: 1, heightSegments: 1, radialSegments: 32 }); + + // combine into single geometry + const combine = new pc.Geometry(); + const pushGeom = (g, vertexOffset) => { + // positions / normals / uvs + combine.positions.push(...g.positions); + if (g.normals) combine.normals ??= []; if (g.normals) combine.normals.push(...g.normals); + if (g.uvs) combine.uvs ??= []; if (g.uvs) combine.uvs.push(...g.uvs); + + // indices with offset + const base = vertexOffset; + const srcIdx = g.indices; + for (let i = 0; i < srcIdx.length; i++) combine.indices.push(srcIdx[i] + base); + }; + + // initialize arrays + combine.positions = []; + combine.normals = []; + combine.uvs = []; + combine.indices = []; + + // vertex offsets and firstIndex tracking (in indices) + const vtxCounts = [ + sphereGeom.positions.length / 3, + boxGeom.positions.length / 3, + cylGeom.positions.length / 3 + ]; + const idxCounts = [ + sphereGeom.indices.length, + boxGeom.indices.length, + cylGeom.indices.length + ]; + const firstIndex = [0, idxCounts[0], idxCounts[0] + idxCounts[1]]; + + // append geometries + pushGeom(sphereGeom, 0); + pushGeom(boxGeom, vtxCounts[0]); + pushGeom(cylGeom, vtxCounts[0] + vtxCounts[1]); + + // create mesh + const mesh = pc.Mesh.fromGeometry(app.graphicsDevice, combine); + + // MeshInstance + const meshInst = new pc.MeshInstance(mesh, material); + + // entity to render our MeshInstance + const entity = new pc.Entity('MultiDrawEntity'); + entity.addComponent('render', { meshInstances: [meshInst] }); + app.root.addChild(entity); + + // instancing + const ringCounts = [8, 15, 25]; + const totalInstances = ringCounts[0] + ringCounts[1] + ringCounts[2]; + + const matrices = new Float32Array(totalInstances * 16); + const vbFormat = pc.VertexFormat.getDefaultInstancingFormat(app.graphicsDevice); + const vb = new pc.VertexBuffer(app.graphicsDevice, vbFormat, totalInstances, { data: matrices }); + meshInst.setInstancing(vb); + + // populate matrices on 3 concentric rings; assign groups sequentially + const tmpPos = new pc.Vec3(); + const tmpRot = new pc.Quat(); + const tmpScl = new pc.Vec3(1, 1, 1); + const m = new pc.Mat4(); + + let write = 0; + const radii = [2, 4, 6]; + for (let ring = 0; ring < 3; ring++) { + const n = ringCounts[ring]; + const r = radii[ring]; + for (let i = 0; i < n; i++) { + const a = (i / n) * Math.PI * 2; + tmpPos.set(Math.cos(a) * r, 0, Math.sin(a) * r); + tmpRot.setFromEulerAngles(0, (a * 180) / Math.PI, 0); + m.setTRS(tmpPos, tmpRot, tmpScl); + matrices.set(m.data, write); + write += 16; + } + } + // upload instance buffer + vb.unlock(); + + // multi-draw: 3 draws (sphere, box, cylinder) with different instance counts + // provide firstInstance (instances are packed sequentially per ring) - this is WebGPU only + const firstInstance = [0, ringCounts[0], ringCounts[0] + ringCounts[1]]; + const cmd = meshInst.setMultiDraw(null, 3); + cmd.add(0, idxCounts[0], ringCounts[0], firstIndex[0], 0, firstInstance[0]); + cmd.add(1, idxCounts[1], ringCounts[1], firstIndex[1], 0, firstInstance[1]); + cmd.add(2, idxCounts[2], ringCounts[2], firstIndex[2], 0, firstInstance[2]); + cmd.update(3); + + // orbit camera + let angle = 0; + app.on('update', (dt) => { + angle += dt * 0.2; + camera.setLocalPosition(15 * Math.sin(angle), 7, 15 * Math.cos(angle)); + camera.lookAt(pc.Vec3.ZERO); + + // draw helper lines around each ring to visualize distribution + const linesPositions = []; + const linesColors = []; + const ringColor = [pc.Color.RED, pc.Color.GREEN, pc.Color.YELLOW]; + for (let ring = 0; ring < 3; ring++) { + const n = ringCounts[ring]; + const r = radii[ring]; + const col = ringColor[ring]; + for (let i = 0; i < n; i++) { + const a0 = (i / n) * Math.PI * 2; + const a1 = ((i + 1) % n) / n * Math.PI * 2; + const p0 = new pc.Vec3(Math.cos(a0) * r, 0, Math.sin(a0) * r); + const p1 = new pc.Vec3(Math.cos(a1) * r, 0, Math.sin(a1) * r); + linesPositions.push(p0, p1); + linesColors.push(col, col); + } + } + app.drawLines(linesPositions, linesColors); + }); +}); + +export { app }; diff --git a/examples/src/examples/graphics/multi-draw.example.mjs b/examples/src/examples/graphics/multi-draw.example.mjs new file mode 100644 index 00000000000..08d6fd34cbb --- /dev/null +++ b/examples/src/examples/graphics/multi-draw.example.mjs @@ -0,0 +1,219 @@ +// @config DESCRIPTION Terrain rendering using a single draw call built from a grid of displaced planes. Each patch is a sub-draw and can be culled (hidden) dynamically. +import { deviceType, rootPath, fileImport } from 'examples/utils'; +import * as pc from 'playcanvas'; + +const canvas = /** @type {HTMLCanvasElement} */ (document.getElementById('application-canvas')); +window.focus(); + +const assets = { + helipad: new pc.Asset( + 'helipad-env-atlas', + 'texture', + { url: `${rootPath}/static/assets/cubemaps/table-mountain-env-atlas.png` }, + { type: pc.TEXTURETYPE_RGBP, mipmaps: false } + ), + height: new pc.Asset( + 'height', + 'texture', + { url: `${rootPath}/static/assets/textures/terrain/Canyon-Height.jpg` } + ), + diffuse: new pc.Asset( + 'diffuse', + 'texture', + { url: `${rootPath}/static/assets/textures/terrain/Canyon-Diffuse.jpg` } + ) +}; + +const gfxOptions = { + deviceTypes: [deviceType], + glslangUrl: `${rootPath}/static/lib/glslang/glslang.js`, + twgslUrl: `${rootPath}/static/lib/twgsl/twgsl.js` +}; + +const { CameraControls } = await fileImport(`${rootPath}/static/scripts/esm/camera-controls.mjs`); + +const device = await pc.createGraphicsDevice(canvas, gfxOptions); +device.maxPixelRatio = Math.min(window.devicePixelRatio, 2); + +const createOptions = new pc.AppOptions(); +createOptions.graphicsDevice = device; +createOptions.componentSystems = [pc.RenderComponentSystem, pc.CameraComponentSystem, pc.ScriptComponentSystem]; +createOptions.resourceHandlers = [pc.TextureHandler, pc.ScriptHandler]; + +const app = new pc.AppBase(canvas); +app.init(createOptions); + +// Set the canvas to fill the window and automatically change resolution to be the same as the canvas size +app.setCanvasFillMode(pc.FILLMODE_FILL_WINDOW); +app.setCanvasResolution(pc.RESOLUTION_AUTO); + +// Ensure canvas is resized when window changes size +const resize = () => app.resizeCanvas(); +window.addEventListener('resize', resize); +app.on('destroy', () => window.removeEventListener('resize', resize)); + +const assetListLoader = new pc.AssetListLoader(Object.values(assets), app.assets); +assetListLoader.load(() => { + app.start(); + + // setup skydome + app.scene.skyboxMip = 2; + app.scene.exposure = 1; + app.scene.envAtlas = assets.helipad.resource; + app.scene.ambientLight = new pc.Color(0.1, 0.1, 0.1); + + // camera + const camera = new pc.Entity(); + camera.addComponent('camera', { toneMapping: pc.TONEMAP_ACES }); + camera.addComponent('script'); + app.root.addChild(camera); + camera.translate(0, 150, 80); + camera.lookAt(pc.Vec3.ZERO); + const cc = /** @type { any } */ (camera.script.create(CameraControls)); + Object.assign(cc, { + // focusPoint: pc.Vec3.ZERO, + enableFly: false + }); + + // material + const material = new pc.StandardMaterial(); + material.diffuseMap = assets.diffuse.resource; + material.update(); + + // terrain params + const terrainWidth = 120; + const terrainDepth = 120; + const minHeight = -50; + const maxHeight = 50; + const patchesX = 40; + const patchesZ = 40; + const patchSegments = 32; // segments per side for each patch (increased detail) + + // heightmap buffer + const img = assets.height.resource.getSource(); + const bufferWidth = img.width; + const bufferHeight = img.height; + const canvas2d = document.createElement('canvas'); + canvas2d.width = bufferWidth; + canvas2d.height = bufferHeight; + const ctx = canvas2d.getContext('2d'); + ctx.drawImage(img, 0, 0); + const buffer = ctx.getImageData(0, 0, bufferWidth, bufferHeight).data; + + // reusable patch geometry (unit patch centered on origin with given size/segments) + const patchWidth = terrainWidth / patchesX; + const patchDepth = terrainDepth / patchesZ; + const patchGeom = new pc.PlaneGeometry({ + halfExtents: new pc.Vec2(patchWidth * 0.5, patchDepth * 0.5), + widthSegments: patchSegments, + lengthSegments: patchSegments + }); + + // combined buffers + const positions = []; + const uvs = []; + const indices = []; + + // per-patch draw info + const firstIndexPerPatch = []; + const indexCountPerPatch = []; + + // helper to sample height from global (x,z) in world units, stored in R channel of heightmap + const sampleHeight = (x, z) => { + const u = (x + terrainWidth * 0.5) / terrainWidth; + const v = 1 - (z + terrainDepth * 0.5) / terrainDepth; + const ix = Math.max(0, Math.min(bufferWidth - 1, (u * (bufferWidth - 1)) | 0)); + const iy = Math.max(0, Math.min(bufferHeight - 1, (v * (bufferHeight - 1)) | 0)); + const p = (ix + iy * bufferWidth) * 4; + const r = buffer[p] / 255; + return minHeight + (maxHeight - minHeight) * r; + }; + + // build combined mesh from grid of patches + let vertexBase = 0; + for (let pz = 0; pz < patchesZ; pz++) { + for (let px = 0; px < patchesX; px++) { + const centerX = -terrainWidth * 0.5 + (px + 0.5) * patchWidth; + const centerZ = -terrainDepth * 0.5 + (pz + 0.5) * patchDepth; + + // record first index for this patch + firstIndexPerPatch.push(indices.length); + + // positions, uvs + const srcPos = patchGeom.positions; + for (let i = 0; i < srcPos.length; i += 3) { + const lx = srcPos[i + 0]; + const lz = srcPos[i + 2]; + const wx = lx + centerX; + const wz = lz + centerZ; + const wy = sampleHeight(wx, wz); + positions.push(wx, wy, wz); + uvs.push((wx + terrainWidth * 0.5) / terrainWidth, 1 - (wz + terrainDepth * 0.5) / terrainDepth); + } + + // indices + const srcIdx = patchGeom.indices; + for (let i = 0; i < srcIdx.length; i++) { + indices.push(vertexBase + srcIdx[i]); + } + + indexCountPerPatch.push(srcIdx.length); + vertexBase += srcPos.length / 3; + } + } + + // normals after displacement + const normals = pc.calculateNormals(positions, indices); + + // create a single mesh from all patches + const mesh = new pc.Mesh(app.graphicsDevice); + mesh.setPositions(positions); + mesh.setNormals(normals); + mesh.setUvs(0, uvs); + mesh.setIndices(indices); + mesh.update(); + + // MeshInstance + const meshInst = new pc.MeshInstance(mesh, material); + + // entity to render our MeshInstance + const entity = new pc.Entity('TerrainEntity'); + entity.addComponent('render', { meshInstances: [meshInst] }); + app.root.addChild(entity); + + // allocater multi-draw: one sub-draw per patch + const numPatches = patchesX * patchesZ; + const cmd = meshInst.setMultiDraw(null, numPatches); + + const bandRadius = 1; // half-width in grid units (~2-entry band) + const rotRps = 0.1; // revolutions per second for spinning line + let time = 0; + + app.on('update', (dt) => { + time += dt; + // spinning band: infinite line through grid center with angle theta; hide patches within distance <= bandRadius + const cx = (patchesX - 1) * 0.5; + const cz = (patchesZ - 1) * 0.5; + const theta = time * rotRps * Math.PI * 2; + const s = Math.sin(theta); + const c = Math.cos(theta); + + // repack visible draws into the front of the arrays, hiding patches within diagonal band + let write = 0; + for (let pz = 0; pz < patchesZ; pz++) { + for (let px = 0; px < patchesX; px++) { + // perpendicular distance in grid units to line through (cx,cz) at angle theta + const dx = px - cx; + const dz = pz - cz; + const dist = Math.abs(dx * s - dz * c); + if (dist <= bandRadius) continue; // hidden by sweeping band + const idx = pz * patchesX + px; + cmd.add(write, indexCountPerPatch[idx], 1, firstIndexPerPatch[idx], 0, 0); + write++; + } + } + cmd.update(write); + }); +}); + +export { app }; diff --git a/examples/thumbnails/graphics_multi-draw-instanced_large.webp b/examples/thumbnails/graphics_multi-draw-instanced_large.webp new file mode 100644 index 00000000000..907e0bcc283 Binary files /dev/null and b/examples/thumbnails/graphics_multi-draw-instanced_large.webp differ diff --git a/examples/thumbnails/graphics_multi-draw-instanced_small.webp b/examples/thumbnails/graphics_multi-draw-instanced_small.webp new file mode 100644 index 00000000000..a9863d841d8 Binary files /dev/null and b/examples/thumbnails/graphics_multi-draw-instanced_small.webp differ diff --git a/examples/thumbnails/graphics_multi-draw_large.webp b/examples/thumbnails/graphics_multi-draw_large.webp new file mode 100644 index 00000000000..db0d27c3fbc Binary files /dev/null and b/examples/thumbnails/graphics_multi-draw_large.webp differ diff --git a/examples/thumbnails/graphics_multi-draw_small.webp b/examples/thumbnails/graphics_multi-draw_small.webp new file mode 100644 index 00000000000..37b57bfedd8 Binary files /dev/null and b/examples/thumbnails/graphics_multi-draw_small.webp differ diff --git a/src/index.js b/src/index.js index 94e07da1eb4..314c13628f5 100644 --- a/src/index.js +++ b/src/index.js @@ -109,6 +109,7 @@ export { BindGroupFormat, BindUniformBufferFormat, BindTextureFormat, BindStorag export { BlendState } from './platform/graphics/blend-state.js'; export { Compute } from './platform/graphics/compute.js'; export { DepthState } from './platform/graphics/depth-state.js'; +export { DrawCommands } from './platform/graphics/draw-commands.js'; export { GraphicsDevice } from './platform/graphics/graphics-device.js'; export { IndexBuffer } from './platform/graphics/index-buffer.js'; export { RenderTarget } from './platform/graphics/render-target.js'; diff --git a/src/platform/graphics/constants.js b/src/platform/graphics/constants.js index b66516bbfc7..d57df9f7a6e 100644 --- a/src/platform/graphics/constants.js +++ b/src/platform/graphics/constants.js @@ -463,6 +463,14 @@ export const INDEXFORMAT_UINT16 = 1; */ export const INDEXFORMAT_UINT32 = 2; +/** + * Byte size of index formats. + * + * @category Graphics + * @ignore + */ +export const indexFormatByteSize = [1, 2, 4]; + export const PIXELFORMAT_A8 = 0; export const PIXELFORMAT_L8 = 1; export const PIXELFORMAT_LA8 = 2; diff --git a/src/platform/graphics/draw-commands.js b/src/platform/graphics/draw-commands.js new file mode 100644 index 00000000000..a15c9cec620 --- /dev/null +++ b/src/platform/graphics/draw-commands.js @@ -0,0 +1,134 @@ +import { Debug } from '../../core/debug.js'; + +/** + * Container holding parameters for multi-draw commands. + * + * Obtain an instance via {@link MeshInstance#setMultiDraw} and populate it using + * {@link DrawCommands#add} followed by {@link DrawCommands#update}. + * + * @category Graphics + */ +class DrawCommands { + /** + * Graphics device used to determine backend (WebGPU vs WebGL). + * + * @type {import('./graphics-device.js').GraphicsDevice} + * @ignore + */ + device; + + /** + * Size of single index in bytes for WebGL multi-draw (1, 2 or 4). 0 represents non-indexed draw. + * + * @type {number} + * @ignore + */ + indexSizeBytes; + + /** + * Maximum number of multi-draw calls the space is allocated for. Ignored for indirect draw commands. + * + * @type {number} + * @private + */ + _maxCount = 0; + + /** + * Maximum number of multi-draw calls the space is allocated for. + * + * @type {number} + */ + get maxCount() { + return this._maxCount; + } + + /** + * Platform-specific implementation. + * + * @type {any} + * @ignore + */ + impl = null; + + /** + * Number of draw calls to perform. + * + * @type {number} + * @private + */ + _count = 1; + + /** + * Number of draw calls to perform. + * + * @type {number} + */ + get count() { + return this._count; + } + + /** + * Slot index of the first indirect draw call. Ignored for multi-draw commands. + * + * @type {number} + * @ignore + */ + slotIndex = 0; + + /** + * @param {import('./graphics-device.js').GraphicsDevice} device - The graphics device. + * @param {number} [indexSizeBytes] - Size of index in bytes for WebGL multi-draw (1, 2 or 4). + * @ignore + */ + constructor(device, indexSizeBytes = 0) { + this.device = device; + this.indexSizeBytes = indexSizeBytes; + this.impl = device.createDrawCommandImpl(this); + } + + /** + * @ignore + */ + destroy() { + this.impl?.destroy?.(); + this.impl = null; + } + + /** + * Allocates persistent storage for the draw commands. + * + * @param {number} maxCount - Maximum number of draw calls to allocate storage for. + * @ignore + */ + allocate(maxCount) { + this._maxCount = maxCount; + this.impl.allocate?.(maxCount); + } + + /** + * Writes one draw command into the allocated storage. + * + * @param {number} i - Draw index to update. + * @param {number} indexOrVertexCount - Number of indices or vertices to draw. + * @param {number} instanceCount - Number of instances to draw (use 1 if not instanced). + * @param {number} firstIndexOrVertex - Starting index (in indices, not bytes) or starting vertex. + * @param {number} [baseVertex] - Signed base vertex (WebGPU only). Defaults to 0. + * @param {number} [firstInstance] - First instance (WebGPU only). Defaults to 0. + */ + add(i, indexOrVertexCount, instanceCount, firstIndexOrVertex, baseVertex = 0, firstInstance = 0) { + Debug.assert(i >= 0 && i < this._maxCount); + this.impl.add(i, indexOrVertexCount, instanceCount, firstIndexOrVertex, baseVertex, firstInstance); + } + + /** + * Finalize and set draw count after all commands have been added. + * + * @param {number} count - Number of draws to execute. + */ + update(count) { + this._count = count; + this.impl.update?.(count); + } +} + +export { DrawCommands }; diff --git a/src/platform/graphics/graphics-device.js b/src/platform/graphics/graphics-device.js index 8dfa5e235e0..4584d701f48 100644 --- a/src/platform/graphics/graphics-device.js +++ b/src/platform/graphics/graphics-device.js @@ -32,6 +32,7 @@ import { DebugGraphics } from './debug-graphics.js'; * @import { Shader } from './shader.js' * @import { Texture } from './texture.js' * @import { StorageBuffer } from './storage-buffer.js'; + * @import { DrawCommands } from './draw-commands.js'; */ const _tempSet = new Set(); @@ -208,6 +209,14 @@ class GraphicsDevice extends EventHandler { */ supportsStencil; + /** + * True if the device supports multi-draw. This is always supported on WebGPU, and support on + * WebGL2 is optional, but pretty common. + * + * @type {boolean} + */ + supportsMultiDraw = true; + /** * True if the device supports compute shaders. * @@ -830,8 +839,7 @@ class GraphicsDevice extends EventHandler { * @param {IndexBuffer} [indexBuffer] - The index buffer to use for the draw call. * @param {number} [numInstances] - The number of instances to render when using instancing. * Defaults to 1. - * @param {{ index: number, count: number }} [indirectData] - The indirect draw data to use for - * the draw call. + * @param {DrawCommands} [drawCommands] - The draw commands to use for the draw call. * @param {boolean} [first] - True if this is the first draw call in a sequence of draw calls. * When set to true, vertex and index buffers related state is set up. Defaults to true. * @param {boolean} [last] - True if this is the last draw call in a sequence of draw calls. @@ -847,7 +855,7 @@ class GraphicsDevice extends EventHandler { * * @ignore */ - draw(primitive, indexBuffer, numInstances, indirectData, first = true, last = true) { + draw(primitive, indexBuffer, numInstances, drawCommands, first = true, last = true) { Debug.assert(false); } diff --git a/src/platform/graphics/null/null-draw-commands.js b/src/platform/graphics/null/null-draw-commands.js new file mode 100644 index 00000000000..ab6dcf5da58 --- /dev/null +++ b/src/platform/graphics/null/null-draw-commands.js @@ -0,0 +1,11 @@ +/** + * Null implementation of DrawCommands. + * + * @ignore + */ +class NullDrawCommands { + add(i, indexOrVertexCount, instanceCount, firstIndexOrVertex) { + } +} + +export { NullDrawCommands }; diff --git a/src/platform/graphics/null/null-graphics-device.js b/src/platform/graphics/null/null-graphics-device.js index 82accbe6b47..c6cc0a5e13b 100644 --- a/src/platform/graphics/null/null-graphics-device.js +++ b/src/platform/graphics/null/null-graphics-device.js @@ -9,6 +9,7 @@ import { NullRenderTarget } from './null-render-target.js'; import { NullShader } from './null-shader.js'; import { NullTexture } from './null-texture.js'; import { NullVertexBuffer } from './null-vertex-buffer.js'; +import { NullDrawCommands } from './null-draw-commands.js'; class NullGraphicsDevice extends GraphicsDevice { constructor(canvas, options = {}) { @@ -96,7 +97,11 @@ class NullGraphicsDevice extends GraphicsDevice { return new NullRenderTarget(renderTarget); } - draw(primitive, indexBuffer, numInstances, indirectData, first = true, last = true) { + createDrawCommandImpl(drawCommands) { + return new NullDrawCommands(); + } + + draw(primitive, indexBuffer, numInstances, drawCommands, first = true, last = true) { } setShader(shader, asyncCompile = false) { diff --git a/src/platform/graphics/webgl/webgl-draw-commands.js b/src/platform/graphics/webgl/webgl-draw-commands.js new file mode 100644 index 00000000000..bc331e70ced --- /dev/null +++ b/src/platform/graphics/webgl/webgl-draw-commands.js @@ -0,0 +1,50 @@ +/** + * WebGL implementation of DrawCommands. + * + * @ignore + */ +class WebglDrawCommands { + /** @type {number} */ + indexSizeBytes; + + /** @type {Int32Array|null} */ + glCounts = null; + + /** @type {Int32Array|null} */ + glOffsetsBytes = null; + + /** @type {Int32Array|null} */ + glInstanceCounts = null; + + /** + * @param {number} indexSizeBytes - Size of index in bytes (1, 2 or 4). 0 for non-indexed. + */ + constructor(indexSizeBytes) { + this.indexSizeBytes = indexSizeBytes; + } + + /** + * Allocate SoA arrays for multi-draw. + * @param {number} maxCount - Number of sub-draws. + */ + allocate(maxCount) { + this.glCounts = new Int32Array(maxCount); + this.glOffsetsBytes = new Int32Array(maxCount); + this.glInstanceCounts = new Int32Array(maxCount); + } + + /** + * Write a single draw entry. + * @param {number} i - Draw index. + * @param {number} indexOrVertexCount - Count of indices/vertices. + * @param {number} instanceCount - Instance count. + * @param {number} firstIndexOrVertex - First index/vertex. + */ + add(i, indexOrVertexCount, instanceCount, firstIndexOrVertex) { + this.glCounts[i] = indexOrVertexCount; + this.glOffsetsBytes[i] = firstIndexOrVertex * this.indexSizeBytes; + this.glInstanceCounts[i] = instanceCount; + } +} + +export { WebglDrawCommands }; diff --git a/src/platform/graphics/webgl/webgl-graphics-device.js b/src/platform/graphics/webgl/webgl-graphics-device.js index 6e07027d4f1..d85e199cc6d 100644 --- a/src/platform/graphics/webgl/webgl-graphics-device.js +++ b/src/platform/graphics/webgl/webgl-graphics-device.js @@ -33,6 +33,7 @@ import { DebugGraphics } from '../debug-graphics.js'; import { WebglVertexBuffer } from './webgl-vertex-buffer.js'; import { WebglIndexBuffer } from './webgl-index-buffer.js'; import { WebglShader } from './webgl-shader.js'; +import { WebglDrawCommands } from './webgl-draw-commands.js'; import { WebglTexture } from './webgl-texture.js'; import { WebglRenderTarget } from './webgl-render-target.js'; import { BlendState } from '../blend-state.js'; @@ -672,6 +673,10 @@ class WebglGraphicsDevice extends GraphicsDevice { return new WebglShader(shader); } + createDrawCommandImpl(drawCommands) { + return new WebglDrawCommands(drawCommands.indexSizeBytes); + } + createTextureImpl(texture) { return new WebglTexture(texture); } @@ -788,6 +793,9 @@ class WebglGraphicsDevice extends GraphicsDevice { this.extTextureFilterAnisotropic = this.getExtension('EXT_texture_filter_anisotropic', 'WEBKIT_EXT_texture_filter_anisotropic'); this.extParallelShaderCompile = this.getExtension('KHR_parallel_shader_compile'); + this.extMultiDraw = this.getExtension('WEBGL_multi_draw'); + this.supportsMultiDraw = !!this.extMultiDraw; + // compressed textures this.extCompressedTextureETC1 = this.getExtension('WEBGL_compressed_texture_etc1'); this.extCompressedTextureETC = this.getExtension('WEBGL_compressed_texture_etc'); @@ -1675,7 +1683,39 @@ class WebglGraphicsDevice extends GraphicsDevice { gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, bufferId); } - draw(primitive, indexBuffer, numInstances, indirectData, first = true, last = true) { + _multiDrawLoopFallback(mode, primitive, indexBuffer, numInstances, drawCommands) { + + const gl = this.gl; + + if (primitive.indexed) { + const format = indexBuffer.impl.glFormat; + const { glCounts, glOffsetsBytes, glInstanceCounts, count } = drawCommands.impl; + + if (numInstances > 0) { + for (let i = 0; i < count; i++) { + gl.drawElementsInstanced(mode, glCounts[i], format, glOffsetsBytes[i], glInstanceCounts[i]); + } + } else { + for (let i = 0; i < count; i++) { + gl.drawElements(mode, glCounts[i], format, glOffsetsBytes[i]); + } + } + } else { + const { glCounts, glOffsetsBytes, glInstanceCounts, count } = drawCommands.impl; + + if (numInstances > 0) { + for (let i = 0; i < count; i++) { + gl.drawArraysInstanced(mode, glOffsetsBytes[i], glCounts[i], glInstanceCounts[i]); + } + } else { + for (let i = 0; i < count; i++) { + gl.drawArrays(mode, glOffsetsBytes[i], glCounts[i]); + } + } + } + } + + draw(primitive, indexBuffer, numInstances, drawCommands, first = true, last = true) { const shader = this.shader; if (shader) { @@ -1786,24 +1826,50 @@ class WebglGraphicsDevice extends GraphicsDevice { const mode = this.glPrimitive[primitive.type]; const count = primitive.count; - if (primitive.indexed) { - Debug.assert(indexBuffer.device === this, 'The IndexBuffer was not created using current GraphicsDevice'); + if (drawCommands) { // multi-draw path - const format = indexBuffer.impl.glFormat; - const offset = primitive.base * indexBuffer.bytesPerIndex; + // multi-draw extension is supported + if (this.extMultiDraw) { + const impl = drawCommands.impl; + if (primitive.indexed) { + const format = indexBuffer.impl.glFormat; - if (numInstances > 0) { - gl.drawElementsInstanced(mode, count, format, offset, numInstances); + if (numInstances > 0) { + this.extMultiDraw.multiDrawElementsInstancedWEBGL(mode, impl.glCounts, 0, format, impl.glOffsetsBytes, 0, impl.glInstanceCounts, 0, drawCommands.count); + } else { + this.extMultiDraw.multiDrawElementsWEBGL(mode, impl.glCounts, 0, format, impl.glOffsetsBytes, 0, drawCommands.count); + } + } else { + if (numInstances > 0) { + this.extMultiDraw.multiDrawArraysInstancedWEBGL(mode, impl.glOffsetsBytes, 0, impl.glCounts, 0, impl.glInstanceCounts, 0, drawCommands.count); + } else { + this.extMultiDraw.multiDrawArraysWEBGL(mode, impl.glOffsetsBytes, 0, impl.glCounts, 0, drawCommands.count); + } + } } else { - gl.drawElements(mode, count, format, offset); + // multi-draw extension is not supported, use fallback loop + this._multiDrawLoopFallback(mode, primitive, indexBuffer, numInstances, drawCommands); } } else { - const first = primitive.base; + if (primitive.indexed) { + Debug.assert(indexBuffer.device === this, 'The IndexBuffer was not created using current GraphicsDevice'); + + const format = indexBuffer.impl.glFormat; + const offset = primitive.base * indexBuffer.bytesPerIndex; - if (numInstances > 0) { - gl.drawArraysInstanced(mode, first, count, numInstances); + if (numInstances > 0) { + gl.drawElementsInstanced(mode, count, format, offset, numInstances); + } else { + gl.drawElements(mode, count, format, offset); + } } else { - gl.drawArrays(mode, first, count); + const first = primitive.base; + + if (numInstances > 0) { + gl.drawArraysInstanced(mode, first, count, numInstances); + } else { + gl.drawArrays(mode, first, count); + } } } diff --git a/src/platform/graphics/webgpu/webgpu-draw-commands.js b/src/platform/graphics/webgpu/webgpu-draw-commands.js new file mode 100644 index 00000000000..10112bd16f0 --- /dev/null +++ b/src/platform/graphics/webgpu/webgpu-draw-commands.js @@ -0,0 +1,80 @@ +import { BUFFERUSAGE_COPY_DST, BUFFERUSAGE_INDIRECT } from '../constants.js'; +import { StorageBuffer } from '../storage-buffer.js'; + +/** + * @import { GraphicsDevice } from '../graphics-device.js' + */ + +/** + * WebGPU implementation of DrawCommands. + * + * @ignore + */ +class WebgpuDrawCommands { + /** @type {GraphicsDevice} */ + device; + + /** @type {Uint32Array|null} */ + gpuIndirect = null; + + /** @type {Int32Array|null} */ + gpuIndirectSigned = null; + + /** + * @type {StorageBuffer|null} + */ + storage = null; + + /** + * @param {GraphicsDevice} device - Graphics device. + */ + constructor(device) { + this.device = device; + } + + /** + * Allocate AoS buffer and backing storage buffer. + * @param {number} maxCount - Number of sub-draws. + */ + allocate(maxCount) { + this.gpuIndirect = new Uint32Array(5 * maxCount); + this.gpuIndirectSigned = new Int32Array(this.gpuIndirect.buffer); + this.storage = new StorageBuffer(this.device, this.gpuIndirect.byteLength, BUFFERUSAGE_INDIRECT | BUFFERUSAGE_COPY_DST); + } + + /** + * Write a single draw entry. + * @param {number} i - Draw index. + * @param {number} indexOrVertexCount - Count of indices/vertices. + * @param {number} instanceCount - Instance count. + * @param {number} firstIndexOrVertex - First index/vertex. + * @param {number} baseVertex - Base vertex (signed). + * @param {number} firstInstance - First instance. + */ + add(i, indexOrVertexCount, instanceCount, firstIndexOrVertex, baseVertex = 0, firstInstance = 0) { + const o = i * 5; + this.gpuIndirect[o + 0] = indexOrVertexCount; + this.gpuIndirect[o + 1] = instanceCount; + this.gpuIndirect[o + 2] = firstIndexOrVertex; + this.gpuIndirectSigned[o + 3] = baseVertex; + this.gpuIndirect[o + 4] = firstInstance; + } + + /** + * Upload AoS data to storage buffer. + * @param {number} count - Number of active draws. + */ + update(count) { + if (this.storage && count > 0) { + const used = count * 5; // 5 uints per draw + this.storage.write(0, this.gpuIndirect, 0, used); + } + } + + destroy() { + this.storage?.destroy(); + this.storage = null; + } +} + +export { WebgpuDrawCommands }; diff --git a/src/platform/graphics/webgpu/webgpu-graphics-device.js b/src/platform/graphics/webgpu/webgpu-graphics-device.js index d62c232f99a..81da1a3d5c6 100644 --- a/src/platform/graphics/webgpu/webgpu-graphics-device.js +++ b/src/platform/graphics/webgpu/webgpu-graphics-device.js @@ -31,6 +31,7 @@ import { WebgpuResolver } from './webgpu-resolver.js'; import { WebgpuCompute } from './webgpu-compute.js'; import { WebgpuBuffer } from './webgpu-buffer.js'; import { StorageBuffer } from '../storage-buffer.js'; +import { WebgpuDrawCommands } from './webgpu-draw-commands.js'; /** * @import { RenderPass } from '../render-pass.js' @@ -503,6 +504,10 @@ class WebgpuGraphicsDevice extends GraphicsDevice { return new WebgpuShader(shader); } + createDrawCommandImpl(drawCommands) { + return new WebgpuDrawCommands(this); + } + createTextureImpl(texture) { return new WebgpuTexture(texture); } @@ -615,7 +620,7 @@ class WebgpuGraphicsDevice extends GraphicsDevice { _uniqueLocations.clear(); } - draw(primitive, indexBuffer, numInstances = 1, indirectData, first = true, last = true) { + draw(primitive, indexBuffer, numInstances = 1, drawCommands, first = true, last = true) { if (this.shader.ready && !this.shader.failed) { @@ -659,20 +664,23 @@ class WebgpuGraphicsDevice extends GraphicsDevice { } // draw - if (indirectData !== undefined) { - const indirectBuffer = this.indirectDrawBuffer.impl.buffer; - const drawsCount = indirectData.count; + if (drawCommands) { // indirect draw path + + const storage = drawCommands.impl?.storage ?? this.indirectDrawBuffer; + const indirectBuffer = storage.impl.buffer; + const drawsCount = drawCommands.count; // TODO: when multiDrawIndirect is supported, we can use it here instead of a loop for (let d = 0; d < drawsCount; d++) { - const indirectOffset = (indirectData.index + d) * _indirectEntryByteSize; + const indirectOffset = (drawCommands.slotIndex + d) * _indirectEntryByteSize; if (indexBuffer) { passEncoder.drawIndexedIndirect(indirectBuffer, indirectOffset); } else { passEncoder.drawIndirect(indirectBuffer, indirectOffset); } } - } else { + } else { // single draw path + if (indexBuffer) { passEncoder.drawIndexed(primitive.count, numInstances, primitive.base, primitive.baseVertex ?? 0, 0); } else { diff --git a/src/scene/mesh-instance.js b/src/scene/mesh-instance.js index a7e296b0980..9688ea904e5 100644 --- a/src/scene/mesh-instance.js +++ b/src/scene/mesh-instance.js @@ -3,6 +3,8 @@ import { BoundingBox } from '../core/shape/bounding-box.js'; import { BoundingSphere } from '../core/shape/bounding-sphere.js'; import { BindGroup } from '../platform/graphics/bind-group.js'; import { UniformBuffer } from '../platform/graphics/uniform-buffer.js'; +import { DrawCommands } from '../platform/graphics/draw-commands.js'; +import { indexFormatByteSize } from '../platform/graphics/constants.js'; import { LAYER_WORLD, MASK_AFFECT_DYNAMIC, MASK_BAKE, MASK_AFFECT_LIGHTMAPPED, @@ -82,38 +84,6 @@ class InstancingData { } } -/** - * Internal data structure used to store data used by indirect rendering. - * - * @ignore - */ -class IndirectData { - /** - * A map of camera components to their corresponding indirect draw data. - * - * @type {Map} - */ - map = new Map(); - - /** - * An array of 4 integers used to store mesh metadata needed for indirect rendering. - * - * @type {Int32Array} - */ - meshMetaData = new Int32Array(4); - - /** - * Retrieves the indirect draw data for a specific camera. - * - * @param {CameraComponent|null} camera - The camera component to retrieve indirect data for, or - * null if the data should be used for all cameras. - * @returns {{ index: number, count: number }|undefined} - The indirect draw data, or undefined. - */ - get(camera) { - return this.map.get(camera) ?? this.map.get(null); - } -} - /** * Internal helper class for storing the shader and related mesh bind group in the shader cache. * @@ -248,6 +218,25 @@ class ShaderInstance { * * - {@link https://playcanvas.github.io/#compute/indirect-draw compute/indirect-draw} * + * ### Multi-draw + * + * Multi-draw lets the engine submit multiple sub-draws with a single API call. On WebGL2 this maps + * to the `WEBGL_multi_draw` extension; on WebGPU, to indirect multi-draw. Use {@link setMultiDraw} + * to allocate a {@link DrawCommands} container, fill it with sub-draws using + * {@link DrawCommands#add} and finalize with {@link DrawCommands#update} whenever the data changes. + * + * Support: {@link GraphicsDevice#supportsMultiDraw} is true on WebGPU and commonly true on WebGL2 + * (high coverage). When not supported, the engine can still render by issuing a fast internal loop + * of single draws using the multi-draw data. + * + * ```javascript + * // two indexed sub-draws from a single mesh + * const cmd = meshInstance.setMultiDraw(null, 2); + * cmd.add(0, 36, 1, 0); + * cmd.add(1, 60, 1, 36); + * cmd.update(2); + * ``` + * * @category Graphics */ class MeshInstance { @@ -350,11 +339,28 @@ class MeshInstance { instancingData = null; /** - * @type {IndirectData|null} + * @type {DrawCommands|null} * @ignore */ indirectData = null; + /** + * Map of camera to their corresponding indirect draw data. Lazily allocated. + * + * @type {Map|null} + * @ignore + */ + drawCommands = null; + + /** + * Stores mesh metadata used for indirect rendering. Lazily allocated on first access + * via getIndirectMetaData(). + * + * @type {Int32Array|null} + * @ignore + */ + meshMetaData = null; + /** * @type {Record} * @ignore @@ -1036,6 +1042,17 @@ class MeshInstance { this.material = null; this.instancingData?.destroy(); + + this.destroyDrawCommands(); + } + + destroyDrawCommands() { + if (this.drawCommands) { + for (const cmd of this.drawCommands.values()) { + cmd?.destroy(); + } + this.drawCommands = null; + } } // shader uniform names for lightmaps @@ -1145,20 +1162,94 @@ class MeshInstance { * @param {CameraComponent|null} camera - Camera component to set indirect data for, or * null if the indirect slot should be used for all cameras. * @param {number} slot - Slot in the buffer to set the draw call parameters. Allocate a slot - * in the buffer by calling {@link GraphicsDevice#getIndirectDrawSlot}. - * @param {number} [count] - Optional number of consecutive slots to reserve and use. Defaults - * to 1. + * in the buffer by calling {@link GraphicsDevice#getIndirectDrawSlot}. Pass -1 to disable + * indirect rendering for the specified camera (or the shared entry when camera is null). + * @param {number} [count] - Optional number of consecutive slots to use. Defaults to 1. */ setIndirect(camera, slot, count = 1) { + const key = camera?.camera ?? null; - this._allocIndirectData(); + // disable when slot is -1 + if (slot === -1) { + this._deleteDrawCommandsKey(key); + } else { + + // lazy map allocation + this.drawCommands ??= new Map(); - // store camera to slot mapping - this.indirectData.map.set(camera?.camera ?? null, { index: slot, count }); + // allocate or get per-camera command + const cmd = this.drawCommands.get(key) ?? new DrawCommands(this.mesh.device); + cmd.slotIndex = slot; + cmd.update(count); + this.drawCommands.set(key, cmd); - // remove all data from this map at the end of the frame, slot needs to be assigned each frame - const device = this.mesh.device; - device.mapsToClear.add(this.indirectData.map); + // remove all data from this map at the end of the frame, slot needs to be assigned each frame + const device = this.mesh.device; + device.mapsToClear.add(this.drawCommands); + } + } + + /** + * Sets the {@link MeshInstance} to be rendered using multi-draw, where multiple sub-draws are + * executed with a single draw call. + * + * @param {CameraComponent|null} camera - Camera component to bind commands to, or null to share + * across all cameras. + * @param {number} [maxCount] - Maximum number of sub-draws to allocate. Defaults to 1. Pass 0 + * to disable multi-draw for the specified camera (or the shared entry when camera is null). + * @returns {DrawCommands|undefined} The commands container to populate with sub-draw commands. + */ + setMultiDraw(camera, maxCount = 1) { + const key = camera?.camera ?? null; + let cmd; + + // disable when maxCount is 0 + if (maxCount === 0) { + this._deleteDrawCommandsKey(key); + } else { + + // lazy map allocation + this.drawCommands ??= new Map(); + + // allocate or get per-camera command + cmd = this.drawCommands.get(key); + if (!cmd) { + // determine index size from current mesh index buffer + const indexBuffer = this.mesh.indexBuffer?.[0]; + const indexFormat = indexBuffer?.format; + const indexSizeBytes = (indexFormat !== undefined) ? indexFormatByteSize[indexFormat] : 0; + cmd = new DrawCommands(this.mesh.device, indexSizeBytes); + this.drawCommands.set(key, cmd); + } + cmd.allocate(maxCount); + } + return cmd; + } + + _deleteDrawCommandsKey(key) { + const cmds = this.drawCommands; + if (cmds) { + const cmd = cmds.get(key); + cmd?.destroy(); + cmds.delete(key); + if (cmds.size === 0) { + this.destroyDrawCommands(); + } + } + } + + /** + * Retrieves the draw commands for a specific camera, or the default commands when none are + * bound to that camera. + * + * @param {Camera} camera - The camera to retrieve commands for. + * @returns {DrawCommands|undefined} - The draw commands, or undefined. + * @ignore + */ + getDrawCommands(camera) { + const cmds = this.drawCommands; + if (!cmds) return undefined; + return cmds.get(camera) ?? cmds.get(null); } /** @@ -1171,11 +1262,8 @@ class MeshInstance { * always zero and is reserved for future use. */ getIndirectMetaData() { - - this._allocIndirectData(); - const prim = this.mesh?.primitive[this.renderStyle]; - const data = this.indirectData.meshMetaData; + const data = this.meshMetaData ?? (this.meshMetaData = new Int32Array(4)); data[0] = prim.count; data[1] = prim.base; data[2] = prim.baseVertex; @@ -1183,12 +1271,6 @@ class MeshInstance { return data; } - _allocIndirectData() { - if (!this.indirectData) { - this.indirectData = new IndirectData(); - } - } - ensureMaterial(device) { if (!this.material) { Debug.warn(`Mesh attached to entity '${this.node.name}' does not have a material, using a default one.`); diff --git a/src/scene/renderer/forward-renderer.js b/src/scene/renderer/forward-renderer.js index e33637565fe..f0426629010 100644 --- a/src/scene/renderer/forward-renderer.js +++ b/src/scene/renderer/forward-renderer.js @@ -587,6 +587,7 @@ class ForwardRenderer extends Renderer { const preparedCallsCount = preparedCalls.drawCalls.length; for (let i = 0; i < preparedCallsCount; i++) { + /** @type {MeshInstance} */ const drawCall = preparedCalls.drawCalls[i]; // We have a mesh instance @@ -655,7 +656,7 @@ class ForwardRenderer extends Renderer { drawCallback?.(drawCall, i); - const indirectData = drawCall.indirectData?.get(camera); + const indirectData = drawCall.getDrawCommands(camera); if (viewList) { for (let v = 0; v < viewList.length; v++) { diff --git a/src/scene/renderer/shadow-renderer.js b/src/scene/renderer/shadow-renderer.js index 2c05b07d63f..f4f897049e9 100644 --- a/src/scene/renderer/shadow-renderer.js +++ b/src/scene/renderer/shadow-renderer.js @@ -362,7 +362,7 @@ class ShadowRenderer { // draw const style = meshInstance.renderStyle; - const indirectData = meshInstance.indirectData?.get(camera); + const indirectData = meshInstance.getDrawCommands(camera); device.draw(mesh.primitive[style], mesh.indexBuffer[style], instancingData?.count, indirectData); renderer._shadowDrawCalls++;