playcanvas
Version:
Open-source WebGL/WebGPU 3D engine for the web
325 lines (274 loc) • 11.6 kB
JavaScript
import {
Color, Entity, Layer, RenderTarget, Script, Texture,
ADDRESS_CLAMP_TO_EDGE, FILTER_LINEAR, GAMMA_NONE, PIXELFORMAT_RGBA16F, PIXELFORMAT_SRGBA8,
SHADERLANGUAGE_GLSL, SHADERLANGUAGE_WGSL, TONEMAP_NONE
} from 'playcanvas';
/**
* @import { Material } from 'playcanvas'
*/
// outputPS chunk overrides for the proxy mesh material: keep the lit color in RGB and write 1 to
// A, marking the pixel as covered by the mesh. The render target clears alpha to 0, so the alpha
// channel acts as a coverage mask - splats sampling uncovered pixels are left untinted.
const meshOutputGLSL = /* glsl */`
gl_FragColor.a = 1.0;
`;
const meshOutputWGSL = /* wgsl */`
output.color = vec4f(output.color.rgb, 1.0);
`;
// gsplatModifyPS chunk: per-pixel relighting. The relighting texture is screen-aligned with the
// main camera, so each fragment samples it at its own screen position - no matrix, no per-splat
// flicker at screen edges. The brightness uniform compensates for the gray albedo of the proxy
// mesh material (2 for 0.5 gray albedo) and allows the overall lighting to be brightened.
const splatModifyGLSL = /* glsl */`
uniform sampler2D uRelightMap;
uniform vec4 uScreenSize;
uniform float uRelightBlend;
uniform float uRelightBrightness;
uniform float uRelightBackground;
void modifySplatColor(vec2 gaussianUV, inout vec4 color) {
vec4 lit = textureLod(uRelightMap, gl_FragCoord.xy * uScreenSize.zw, 0.0);
// the texture alpha is a mesh coverage mask - splats not covered by the mesh (e.g. the sky)
// are modulated by the background multiplier instead of the mesh lighting
vec3 factor = mix(vec3(uRelightBackground), lit.rgb * uRelightBrightness, lit.a);
color.rgb = mix(color.rgb, color.rgb * factor, uRelightBlend);
}
`;
const splatModifyWGSL = /* wgsl */`
var uRelightMap: texture_2d<f32>;
var uRelightMapSampler: sampler;
uniform uScreenSize: vec4f;
uniform uRelightBlend: f32;
uniform uRelightBrightness: f32;
uniform uRelightBackground: f32;
fn modifySplatColor(gaussianUV: vec2f, color: ptr<function, vec4f>) {
let lit = textureSampleLevel(uRelightMap, uRelightMapSampler, pcPosition.xy * uniform.uScreenSize.zw, 0.0);
// the texture alpha is a mesh coverage mask - splats not covered by the mesh (e.g. the sky)
// are modulated by the background multiplier instead of the mesh lighting
let factor = mix(vec3f(uniform.uRelightBackground), lit.rgb * uniform.uRelightBrightness, lit.a);
*color = vec4f(mix((*color).rgb, (*color).rgb * factor, uniform.uRelightBlend), (*color).a);
}
`;
/**
* Relights a gaussian splat scene using a proxy mesh. A proxy mesh of the splat scene is lit by
* standard lights and rendered into an offscreen texture (lit mesh color in RGB, mesh coverage
* mask in A) by a camera matching the main camera. The texture is screen-aligned with the main
* camera, so each splat fragment samples the mesh lighting at its own screen position and the
* splat color is modulated by it per pixel.
*
* Attach this script to the entity holding the main camera that renders the gsplat scene. Under
* the hood it creates a child entity with a camera matching the main camera, which renders a
* dedicated layer into the texture before the main camera renders. Place the proxy mesh and the
* lights that should light it on that layer ({@link GSplatRelighting#layer}), and apply
* {@link GSplatRelighting#configureMaterial} to the proxy mesh material so it writes the
* coverage mask to alpha.
*
* Note: only gsplat components in unified mode are supported - the splat customization is
* applied to `app.scene.gsplat.material`, and only by the raster gsplat renderers (CPU and GPU
* sort), as the fragment chunk is not used by the compute renderer.
* @category Gaussian Splatting
*/
class GSplatRelighting extends Script {
static scriptName = 'gsplatRelighting';
/**
* Scale of the relighting texture resolution relative to the back buffer.
* @attribute
* @range [0.1, 1]
*/
textureScale = 1;
/**
* Priority of the relighting camera. Keep it lower than the main camera priority (default 0)
* so the relighting texture is rendered first each frame.
* @attribute
*/
priority = -1;
/**
* Name of the layer the proxy mesh and its lights should be placed on. The layer is created
* if it does not exist.
* @attribute
*/
layerName = 'Relighting';
/**
* How much the mesh lighting affects the splat colors. 0 leaves splats unchanged, 1 fully
* modulates the splat color by the mesh lighting.
* @attribute
* @range [0, 1]
*/
blend = 1;
/**
* Brightness of the lighting texture when tinting the splats. The default of 2 compensates
* for the 0.5 gray albedo of the proxy mesh material.
* @attribute
* @range [0, 5]
*/
brightness = 2;
/**
* Multiplier applied to splats not covered by the proxy mesh (e.g. the sky), allowing them
* to follow the environment exposure.
* @attribute
* @range [0, 5]
*/
background = 1;
/** @type {Layer|null} */
_layer = null;
/** @type {boolean} */
_ownsLayer = false;
/** @type {Entity|null} */
_rtEntity = null;
/** @type {Texture|null} */
_texture = null;
/** @type {RenderTarget|null} */
_renderTarget = null;
/** @type {number} */
_format = PIXELFORMAT_RGBA16F;
initialize() {
const camera = this.entity.camera;
if (!camera) {
console.error('GSplatRelighting requires a Camera component on the entity.');
return;
}
// HDR format with alpha for the relighting texture, with LDR fallback when not
// renderable / filterable - sRGB to limit banding as the texture stores linear lighting
this._format = this.app.graphicsDevice.getRenderableHdrFormat([PIXELFORMAT_RGBA16F], true) ??
PIXELFORMAT_SRGBA8;
// find or create the relighting layer
let layer = this.app.scene.layers.getLayerByName(this.layerName);
if (!layer) {
layer = new Layer({ name: this.layerName });
this.app.scene.layers.push(layer);
this._ownsLayer = true;
}
this._layer = layer;
// child entity with a camera matching the host camera, rendering the relighting layer
// into the texture; inherits the host camera world transform automatically
const rtEntity = new Entity('RelightingCamera');
rtEntity.addComponent('camera', {
layers: [layer.id],
priority: this.priority,
clearColor: new Color(0, 0, 0, 0),
fov: camera.fov,
nearClip: camera.nearClip,
farClip: camera.farClip,
// keep the texture linear HDR
toneMapping: TONEMAP_NONE,
gammaCorrection: GAMMA_NONE
});
this.entity.addChild(rtEntity);
this._rtEntity = rtEntity;
this._updateRenderTarget();
this._applySplatChunk();
this.on('enable', () => {
if (this._rtEntity) this._rtEntity.enabled = true;
this._applySplatChunk();
});
this.on('disable', () => {
if (this._rtEntity) this._rtEntity.enabled = false;
this._removeSplatChunk();
});
this.on('destroy', () => {
this._removeSplatChunk();
this._destroyRenderTarget();
this._rtEntity?.destroy();
this._rtEntity = null;
if (this._ownsLayer && this._layer) {
this.app.scene.layers.remove(this._layer);
}
this._layer = null;
});
}
/**
* The layer the proxy mesh and its lights should be placed on.
* @type {Layer|null}
*/
get layer() {
return this._layer;
}
/**
* The relighting texture: lit mesh color in RGB, mesh coverage mask in A.
* @type {Texture|null}
*/
get texture() {
return this._texture;
}
/**
* Overrides the output shader chunk of a material to write the mesh coverage mask to the
* alpha channel, as expected by the relighting effect. Apply this to the proxy mesh
* material.
*
* @param {Material} material - The material to configure.
*/
configureMaterial(material) {
material.getShaderChunks(SHADERLANGUAGE_GLSL).set('outputPS', meshOutputGLSL);
material.getShaderChunks(SHADERLANGUAGE_WGSL).set('outputPS', meshOutputWGSL);
material.shaderChunksVersion = '2.8';
material.update();
}
update() {
const camera = this.entity.camera;
const rtCamera = this._rtEntity?.camera;
if (!camera || !rtCamera) return;
// keep the relighting camera in sync with the host camera
rtCamera.fov = camera.fov;
rtCamera.nearClip = camera.nearClip;
rtCamera.farClip = camera.farClip;
this._updateRenderTarget();
// update the splat customization uniforms on the unified gsplat material
const material = this.app.scene.gsplat?.material;
if (material && this._texture) {
material.setParameter('uRelightMap', this._texture);
material.setParameter('uRelightBlend', this.blend);
material.setParameter('uRelightBrightness', this.brightness);
material.setParameter('uRelightBackground', this.background);
material.update();
}
}
_applySplatChunk() {
const material = this.app.scene.gsplat?.material;
if (!material) return;
const isWebGPU = this.app.graphicsDevice.isWebGPU;
const shaderLanguage = isWebGPU ? SHADERLANGUAGE_WGSL : SHADERLANGUAGE_GLSL;
material.getShaderChunks(shaderLanguage).set('gsplatModifyPS', isWebGPU ? splatModifyWGSL : splatModifyGLSL);
material.update();
}
_removeSplatChunk() {
const material = this.app.scene.gsplat?.material;
if (!material) return;
const shaderLanguage = this.app.graphicsDevice.isWebGPU ? SHADERLANGUAGE_WGSL : SHADERLANGUAGE_GLSL;
material.getShaderChunks(shaderLanguage).delete('gsplatModifyPS');
material.update();
}
_destroyRenderTarget() {
this._renderTarget?.destroy();
this._renderTarget = null;
this._texture?.destroy();
this._texture = null;
}
_updateRenderTarget() {
const device = this.app.graphicsDevice;
const width = Math.max(1, Math.floor(device.width * this.textureScale));
const height = Math.max(1, Math.floor(device.height * this.textureScale));
if (this._texture && this._texture.width === width && this._texture.height === height) {
return;
}
this._destroyRenderTarget();
this._texture = new Texture(device, {
name: 'RelightingTexture',
width: width,
height: height,
format: this._format,
mipmaps: false,
minFilter: FILTER_LINEAR,
magFilter: FILTER_LINEAR,
addressU: ADDRESS_CLAMP_TO_EDGE,
addressV: ADDRESS_CLAMP_TO_EDGE
});
this._renderTarget = new RenderTarget({
name: 'RelightingRT',
colorBuffer: this._texture,
depth: true
});
if (this._rtEntity?.camera) {
this._rtEntity.camera.renderTarget = this._renderTarget;
}
}
}
export { GSplatRelighting };