|
| 1 | +// luma.gl |
| 2 | +// SPDX-License-Identifier: MIT |
| 3 | +// Copyright (c) vis.gl contributors |
| 4 | + |
| 5 | +import {describe, expect, test, vi} from 'vitest'; |
| 6 | +import {Buffer, type Texture} from '@luma.gl/core'; |
| 7 | +import type {AnimationProps} from '@luma.gl/engine'; |
| 8 | +import {fromHalfFloat} from '@luma.gl/shadertools'; |
| 9 | +import {getWebGPUTestDevice} from '@luma.gl/test-utils'; |
| 10 | +import SpectralCausticsAnimationLoopTemplate, { |
| 11 | + type SpectralCausticsExampleProps |
| 12 | +} from '../../examples/experimental/spectral-caustics/app'; |
| 13 | + |
| 14 | +describe('Spectral Caustics: Prism Cathedral', () => { |
| 15 | + test('traces finite HDR caustics and renders them into the floating-point beauty target', async () => { |
| 16 | + const device = await getWebGPUTestDevice('max'); |
| 17 | + if (!device) { |
| 18 | + return; |
| 19 | + } |
| 20 | + |
| 21 | + const host = document.createElement('div'); |
| 22 | + host.id = 'example-panel-host'; |
| 23 | + document.body.append(host); |
| 24 | + let viewer: SpectralCausticsAnimationLoopTemplate | null = null; |
| 25 | + const width = 96; |
| 26 | + const height = 72; |
| 27 | + const mapSize = 64; |
| 28 | + try { |
| 29 | + viewer = new SpectralCausticsAnimationLoopTemplate({ |
| 30 | + device, |
| 31 | + width, |
| 32 | + height, |
| 33 | + captureSize: 32, |
| 34 | + mapSize |
| 35 | + } as SpectralCausticsExampleProps); |
| 36 | + |
| 37 | + expect(viewer.sceneColorTexture.format).toBe('rgba16float'); |
| 38 | + expect(viewer.spectralCausticMap.format).toBe('rgba16float'); |
| 39 | + |
| 40 | + // This assertion targets the two offscreen HDR outputs. The shared test canvas can outlive |
| 41 | + // Dawn's external presentation instance as the complete SwiftShader suite advances through |
| 42 | + // independent files, so do not present the already-verified beauty target in this test. |
| 43 | + const presentationSpy = vi |
| 44 | + .spyOn(viewer.postprocessingRenderer, 'renderToScreen') |
| 45 | + .mockImplementation(() => {}); |
| 46 | + try { |
| 47 | + viewer.onRender(makeAnimationProps(device, width, height)); |
| 48 | + } finally { |
| 49 | + presentationSpy.mockRestore(); |
| 50 | + } |
| 51 | + device.submit(); |
| 52 | + |
| 53 | + const causticXyz = await readRgba16FloatTexture(viewer.spectralCausticMap, mapSize, mapSize); |
| 54 | + expect(causticXyz.every(Number.isFinite)).toBe(true); |
| 55 | + expect(getMaximumRgb(causticXyz)).toBeGreaterThan(1); |
| 56 | + |
| 57 | + const sceneColor = await readRgba16FloatTexture(viewer.sceneColorTexture, width, height); |
| 58 | + expect(sceneColor.every(Number.isFinite)).toBe(true); |
| 59 | + expect(getMaximumRgb(sceneColor)).toBeGreaterThan(1); |
| 60 | + } finally { |
| 61 | + viewer?.onFinalize(); |
| 62 | + host.remove(); |
| 63 | + } |
| 64 | + }, 30_000); |
| 65 | +}); |
| 66 | + |
| 67 | +function makeAnimationProps( |
| 68 | + device: AnimationProps['device'], |
| 69 | + width: number, |
| 70 | + height: number |
| 71 | +): AnimationProps { |
| 72 | + return { |
| 73 | + device, |
| 74 | + tick: 1000, |
| 75 | + time: 1000, |
| 76 | + width, |
| 77 | + height, |
| 78 | + aspect: width / height |
| 79 | + } as AnimationProps; |
| 80 | +} |
| 81 | + |
| 82 | +async function readRgba16FloatTexture( |
| 83 | + texture: Texture, |
| 84 | + width: number, |
| 85 | + height: number |
| 86 | +): Promise<Float32Array> { |
| 87 | + if (texture.format !== 'rgba16float') { |
| 88 | + throw new Error(`Expected rgba16float texture, received ${texture.format}.`); |
| 89 | + } |
| 90 | + const readOptions = {width, height}; |
| 91 | + const layout = texture.computeMemoryLayout(readOptions); |
| 92 | + const readback = texture.device.createBuffer({ |
| 93 | + id: `${texture.id}-example-test-readback`, |
| 94 | + byteLength: layout.byteLength, |
| 95 | + usage: Buffer.COPY_DST | Buffer.MAP_READ |
| 96 | + }); |
| 97 | + try { |
| 98 | + texture.readBuffer(readOptions, readback); |
| 99 | + texture.device.submit(); |
| 100 | + const bytes = await readback.readAsync(0, layout.byteLength); |
| 101 | + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); |
| 102 | + const values = new Float32Array(width * height * 4); |
| 103 | + for (let yCoordinate = 0; yCoordinate < height; yCoordinate++) { |
| 104 | + for (let xCoordinate = 0; xCoordinate < width; xCoordinate++) { |
| 105 | + const valueOffset = (xCoordinate + width * yCoordinate) * 4; |
| 106 | + const byteOffset = yCoordinate * layout.bytesPerRow + xCoordinate * layout.bytesPerPixel; |
| 107 | + for (let channel = 0; channel < 4; channel++) { |
| 108 | + values[valueOffset + channel] = fromHalfFloat( |
| 109 | + view.getUint16(byteOffset + channel * Uint16Array.BYTES_PER_ELEMENT, true) |
| 110 | + ); |
| 111 | + } |
| 112 | + } |
| 113 | + } |
| 114 | + return values; |
| 115 | + } finally { |
| 116 | + readback.destroy(); |
| 117 | + } |
| 118 | +} |
| 119 | + |
| 120 | +function getMaximumRgb(values: Float32Array): number { |
| 121 | + let maximum = 0; |
| 122 | + for (let valueOffset = 0; valueOffset < values.length; valueOffset += 4) { |
| 123 | + maximum = Math.max( |
| 124 | + maximum, |
| 125 | + values[valueOffset], |
| 126 | + values[valueOffset + 1], |
| 127 | + values[valueOffset + 2] |
| 128 | + ); |
| 129 | + } |
| 130 | + return maximum; |
| 131 | +} |
0 commit comments