|
| 1 | +// @config DESCRIPTION Test example for RenderPassRadixSort - GPU radix sort using mipmap binary search |
| 2 | +// @config HIDDEN |
| 3 | +import files from 'examples/files'; |
| 4 | +import { data } from 'examples/observer'; |
| 5 | +import { deviceType, rootPath } from 'examples/utils'; |
| 6 | +import * as pc from 'playcanvas'; |
| 7 | + |
| 8 | +const canvas = /** @type {HTMLCanvasElement} */ (document.getElementById('application-canvas')); |
| 9 | +window.focus(); |
| 10 | + |
| 11 | +const gfxOptions = { |
| 12 | + deviceTypes: [deviceType], |
| 13 | + glslangUrl: `${rootPath}/static/lib/glslang/glslang.js`, |
| 14 | + twgslUrl: `${rootPath}/static/lib/twgsl/twgsl.js` |
| 15 | +}; |
| 16 | + |
| 17 | +const device = await pc.createGraphicsDevice(canvas, gfxOptions); |
| 18 | +device.maxPixelRatio = Math.min(window.devicePixelRatio, 2); |
| 19 | + |
| 20 | +const createOptions = new pc.AppOptions(); |
| 21 | +createOptions.graphicsDevice = device; |
| 22 | + |
| 23 | +createOptions.componentSystems = [pc.RenderComponentSystem, pc.CameraComponentSystem, pc.LightComponentSystem]; |
| 24 | +createOptions.resourceHandlers = [pc.TextureHandler]; |
| 25 | + |
| 26 | +const app = new pc.AppBase(canvas); |
| 27 | +app.init(createOptions); |
| 28 | +app.start(); |
| 29 | + |
| 30 | +app.setCanvasFillMode(pc.FILLMODE_FILL_WINDOW); |
| 31 | +app.setCanvasResolution(pc.RESOLUTION_AUTO); |
| 32 | + |
| 33 | +const resize = () => app.resizeCanvas(); |
| 34 | +window.addEventListener('resize', resize); |
| 35 | +app.on('destroy', () => { |
| 36 | + window.removeEventListener('resize', resize); |
| 37 | +}); |
| 38 | + |
| 39 | +// State |
| 40 | +/** @type {number} */ |
| 41 | +let currentNumElements = 1000 * 1000; // 1M default |
| 42 | +/** @type {number} */ |
| 43 | +let currentNumBits = 16; |
| 44 | +/** @type {pc.Texture|null} */ |
| 45 | +let keysTexture = null; |
| 46 | +/** @type {pc.RenderPassRadixSort|null} */ |
| 47 | +let radixSort = null; |
| 48 | +/** @type {number[]} */ |
| 49 | +let originalValues = []; |
| 50 | +/** @type {boolean} */ |
| 51 | +let needsRegen = true; |
| 52 | + |
| 53 | +// Create render pass instance once |
| 54 | +radixSort = new pc.RenderPassRadixSort(device); |
| 55 | + |
| 56 | +// ==================== MATERIALS ==================== |
| 57 | + |
| 58 | +// Create unsorted visualization material |
| 59 | +const unsortedMaterial = new pc.ShaderMaterial({ |
| 60 | + uniqueName: 'UnsortedVizMaterial', |
| 61 | + vertexGLSL: files['vert.glsl'], |
| 62 | + fragmentGLSL: files['unsorted.glsl.frag'], |
| 63 | + vertexWGSL: files['vert.wgsl'], |
| 64 | + fragmentWGSL: files['unsorted.wgsl.frag'], |
| 65 | + attributes: { |
| 66 | + aPosition: pc.SEMANTIC_POSITION, |
| 67 | + aUv0: pc.SEMANTIC_TEXCOORD0 |
| 68 | + } |
| 69 | +}); |
| 70 | + |
| 71 | +// Create sorted visualization material |
| 72 | +const sortedMaterial = new pc.ShaderMaterial({ |
| 73 | + uniqueName: 'SortedVizMaterial', |
| 74 | + vertexGLSL: files['vert.glsl'], |
| 75 | + fragmentGLSL: files['sorted.glsl.frag'], |
| 76 | + vertexWGSL: files['vert.wgsl'], |
| 77 | + fragmentWGSL: files['sorted.wgsl.frag'], |
| 78 | + attributes: { |
| 79 | + aPosition: pc.SEMANTIC_POSITION, |
| 80 | + aUv0: pc.SEMANTIC_TEXCOORD0 |
| 81 | + } |
| 82 | +}); |
| 83 | + |
| 84 | +// ==================== SCENE SETUP ==================== |
| 85 | + |
| 86 | +// Create camera entity |
| 87 | +const camera = new pc.Entity('camera'); |
| 88 | +camera.addComponent('camera', { |
| 89 | + clearColor: new pc.Color(0.1, 0.1, 0.1), |
| 90 | + projection: pc.PROJECTION_ORTHOGRAPHIC, |
| 91 | + orthoHeight: 1 |
| 92 | +}); |
| 93 | +camera.setPosition(0, 0, 1); |
| 94 | +app.root.addChild(camera); |
| 95 | + |
| 96 | +// Create unsorted visualization plane (top half) |
| 97 | +const unsortedPlane = new pc.Entity('unsortedPlane'); |
| 98 | +unsortedPlane.addComponent('render', { |
| 99 | + type: 'plane', |
| 100 | + material: unsortedMaterial, |
| 101 | + castShadows: false, |
| 102 | + receiveShadows: false |
| 103 | +}); |
| 104 | +unsortedPlane.setLocalPosition(0, 0.5, 0); |
| 105 | +unsortedPlane.setLocalScale(2, 1, 1); |
| 106 | +unsortedPlane.setEulerAngles(90, 0, 0); |
| 107 | +app.root.addChild(unsortedPlane); |
| 108 | + |
| 109 | +// Create sorted visualization plane (bottom half) |
| 110 | +const sortedPlane = new pc.Entity('sortedPlane'); |
| 111 | +sortedPlane.addComponent('render', { |
| 112 | + type: 'plane', |
| 113 | + material: sortedMaterial, |
| 114 | + castShadows: false, |
| 115 | + receiveShadows: false |
| 116 | +}); |
| 117 | +sortedPlane.setLocalPosition(0, -0.5, 0); |
| 118 | +sortedPlane.setLocalScale(2, 1, 1); |
| 119 | +sortedPlane.setEulerAngles(90, 0, 0); |
| 120 | +app.root.addChild(sortedPlane); |
| 121 | + |
| 122 | +// Create spinning cube for visual frame rate indicator |
| 123 | +const cube = new pc.Entity('cube'); |
| 124 | +cube.addComponent('render', { |
| 125 | + type: 'box' |
| 126 | +}); |
| 127 | +cube.setLocalPosition(0, 0, 0.3); |
| 128 | +cube.setLocalScale(0.15, 0.15, 0.15); |
| 129 | +app.root.addChild(cube); |
| 130 | + |
| 131 | +// Create directional light for the cube |
| 132 | +const light = new pc.Entity('light'); |
| 133 | +light.addComponent('light'); |
| 134 | +light.setEulerAngles(45, 30, 0); |
| 135 | +app.root.addChild(light); |
| 136 | + |
| 137 | +// ==================== HELPER FUNCTIONS ==================== |
| 138 | + |
| 139 | +/** |
| 140 | + * Calculates the optimal texture size for storing N elements. |
| 141 | + * |
| 142 | + * @param {number} numElements - Number of elements. |
| 143 | + * @returns {{width: number, height: number}} Texture dimensions. |
| 144 | + */ |
| 145 | +function calcTextureSize(numElements) { |
| 146 | + const pixels = Math.ceil(numElements); |
| 147 | + const size = Math.ceil(Math.sqrt(pixels)); |
| 148 | + return { width: size, height: size }; |
| 149 | +} |
| 150 | + |
| 151 | +/** |
| 152 | + * Recreates the keys texture and generates random data. |
| 153 | + */ |
| 154 | +function regenerateData() { |
| 155 | + const numElements = currentNumElements; |
| 156 | + const numBits = currentNumBits; |
| 157 | + const maxValue = (1 << numBits) - 1; |
| 158 | + |
| 159 | + // Calculate non-POT texture size |
| 160 | + const { width, height } = calcTextureSize(numElements); |
| 161 | + |
| 162 | + // Destroy old texture |
| 163 | + if (keysTexture) { |
| 164 | + keysTexture.destroy(); |
| 165 | + } |
| 166 | + |
| 167 | + // Create new source keys texture |
| 168 | + keysTexture = new pc.Texture(device, { |
| 169 | + name: 'SourceKeys', |
| 170 | + width: width, |
| 171 | + height: height, |
| 172 | + format: pc.PIXELFORMAT_R32U, |
| 173 | + mipmaps: false, |
| 174 | + minFilter: pc.FILTER_NEAREST, |
| 175 | + magFilter: pc.FILTER_NEAREST, |
| 176 | + addressU: pc.ADDRESS_CLAMP_TO_EDGE, |
| 177 | + addressV: pc.ADDRESS_CLAMP_TO_EDGE |
| 178 | + }); |
| 179 | + |
| 180 | + // Generate random test data directly into texture (linear layout) |
| 181 | + const texData = keysTexture.lock(); |
| 182 | + |
| 183 | + // also keep original values for verification |
| 184 | + originalValues = []; |
| 185 | + |
| 186 | + for (let i = 0; i < numElements; i++) { |
| 187 | + const value = Math.floor(Math.random() * maxValue); |
| 188 | + texData[i] = value; |
| 189 | + originalValues.push(value); |
| 190 | + } |
| 191 | + |
| 192 | + // Fill remaining pixels with max value + 1 (will sort to end) |
| 193 | + for (let i = numElements; i < texData.length; i++) { |
| 194 | + texData[i] = maxValue + 1; |
| 195 | + } |
| 196 | + |
| 197 | + keysTexture.unlock(); |
| 198 | + |
| 199 | + needsRegen = false; |
| 200 | +} |
| 201 | + |
| 202 | +/** |
| 203 | + * Runs the GPU sort. |
| 204 | + * |
| 205 | + * @param {boolean} [verify] - Whether to verify results. |
| 206 | + */ |
| 207 | +function runSort(verify = false) { |
| 208 | + if (!keysTexture || !radixSort) return; |
| 209 | + |
| 210 | + // Execute the GPU sort and get the sorted indices texture |
| 211 | + const sortedIndices = radixSort.sort(keysTexture, currentNumElements, currentNumBits); |
| 212 | + |
| 213 | + // Update materials with the sorted texture |
| 214 | + updateMaterialParameters(sortedIndices); |
| 215 | + |
| 216 | + // Verify results if requested |
| 217 | + if (verify) { |
| 218 | + verifyResults(sortedIndices); |
| 219 | + } |
| 220 | +} |
| 221 | + |
| 222 | +/** |
| 223 | + * Updates material parameters after sort completes or data changes. |
| 224 | + * |
| 225 | + * @param {pc.Texture} sortedIndices - The sorted indices texture. |
| 226 | + */ |
| 227 | +function updateMaterialParameters(sortedIndices) { |
| 228 | + if (!keysTexture || !sortedIndices) { |
| 229 | + return; |
| 230 | + } |
| 231 | + |
| 232 | + // Update unsorted material |
| 233 | + unsortedMaterial.setParameter('keysTexture', keysTexture); |
| 234 | + unsortedMaterial.setParameter('maxValue', (1 << currentNumBits) - 1); |
| 235 | + unsortedMaterial.setParameter('elementCount', currentNumElements); |
| 236 | + unsortedMaterial.setParameter('textureSize', [keysTexture.width, keysTexture.height]); |
| 237 | + unsortedMaterial.setParameter('debugMode', 0.0); |
| 238 | + unsortedMaterial.update(); |
| 239 | + |
| 240 | + // Update sorted material |
| 241 | + sortedMaterial.setParameter('sortedIndices', sortedIndices); |
| 242 | + sortedMaterial.setParameter('keysTexture', keysTexture); |
| 243 | + sortedMaterial.setParameter('elementCount', currentNumElements); |
| 244 | + sortedMaterial.setParameter('textureWidth', sortedIndices.width); |
| 245 | + sortedMaterial.setParameter('maxValue', (1 << currentNumBits) - 1); |
| 246 | + sortedMaterial.setParameter('sourceTextureSize', [keysTexture.width, keysTexture.height]); |
| 247 | + sortedMaterial.setParameter('debugMode', 0.0); |
| 248 | + sortedMaterial.update(); |
| 249 | +} |
| 250 | + |
| 251 | +/** |
| 252 | + * Downloads and verifies the sorted results. Only logs if validation fails. |
| 253 | + * |
| 254 | + * @param {pc.Texture} sortedIndices - The sorted indices texture to verify. |
| 255 | + */ |
| 256 | +async function verifyResults(sortedIndices) { |
| 257 | + if (!sortedIndices) { |
| 258 | + console.error('No sorted indices texture available'); |
| 259 | + return; |
| 260 | + } |
| 261 | + |
| 262 | + // Capture current state before async operation (use slice() for large arrays, not spread) |
| 263 | + const capturedOriginalValues = originalValues.slice(); |
| 264 | + const capturedNumElements = currentNumElements; |
| 265 | + const width = sortedIndices.width; |
| 266 | + |
| 267 | + // Read the sorted indices texture (R32U) |
| 268 | + const indicesResult = await sortedIndices.read(0, 0, width, width, { |
| 269 | + immediate: true |
| 270 | + }); |
| 271 | + |
| 272 | + // Extract sorted indices (stored in linear order) |
| 273 | + const sortedIndicesArray = []; |
| 274 | + for (let i = 0; i < capturedNumElements; i++) { |
| 275 | + sortedIndicesArray.push(indicesResult[i]); |
| 276 | + } |
| 277 | + |
| 278 | + // Get sorted values by looking up original values (using captured copy) |
| 279 | + const sortedValues = sortedIndicesArray.map(idx => capturedOriginalValues[idx]); |
| 280 | + |
| 281 | + // Verify sorting - only log on failure |
| 282 | + let errorCount = 0; |
| 283 | + for (let i = 1; i < sortedValues.length; i++) { |
| 284 | + if (sortedValues[i] < sortedValues[i - 1]) { |
| 285 | + if (errorCount < 5) { |
| 286 | + console.error(`Sort error at index ${i}: ${sortedValues[i - 1]} > ${sortedValues[i]}`); |
| 287 | + } |
| 288 | + errorCount++; |
| 289 | + } |
| 290 | + } |
| 291 | + |
| 292 | + if (errorCount > 0) { |
| 293 | + console.error(`✗ [${device.deviceType}] Array is NOT correctly sorted (${errorCount} errors, ${(errorCount / capturedNumElements * 100).toFixed(2)}%)`); |
| 294 | + } |
| 295 | +} |
| 296 | + |
| 297 | +// Initialize control values in the data observer |
| 298 | +data.set('options', { |
| 299 | + elementsK: 1000, |
| 300 | + bits: 16 |
| 301 | +}); |
| 302 | + |
| 303 | +// Handle control changes from data binding |
| 304 | +data.on('*:set', (/** @type {string} */ path, /** @type {any} */ value) => { |
| 305 | + if (path === 'options.elementsK') { |
| 306 | + const newElements = value * 1000; |
| 307 | + if (newElements !== currentNumElements) { |
| 308 | + currentNumElements = newElements; |
| 309 | + needsRegen = true; |
| 310 | + } |
| 311 | + } else if (path === 'options.bits') { |
| 312 | + if (value !== currentNumBits) { |
| 313 | + currentNumBits = value; |
| 314 | + needsRegen = true; |
| 315 | + } |
| 316 | + } |
| 317 | +}); |
| 318 | + |
| 319 | +// Update loop - continuously sorts every frame |
| 320 | +app.on('update', (/** @type {number} */ dt) => { |
| 321 | + // Rotate the cube for visual frame rate indication |
| 322 | + cube.rotate(10 * dt, 20 * dt, 30 * dt); |
| 323 | + |
| 324 | + // Regenerate data when parameters change |
| 325 | + const verify = needsRegen; |
| 326 | + if (needsRegen) { |
| 327 | + regenerateData(); |
| 328 | + } |
| 329 | + |
| 330 | + // Sort every frame, verify only after regeneration |
| 331 | + runSort(verify); |
| 332 | +}); |
| 333 | + |
| 334 | +export { app }; |
0 commit comments