playcanvas
Version:
PlayCanvas WebGL game engine
596 lines (593 loc) • 21.8 kB
JavaScript
import { LAYERID_WORLD } from '../../../scene/constants.js';
import { GSplatInstance } from '../../../scene/gsplat/gsplat-instance.js';
import { Asset } from '../../asset/asset.js';
import { AssetReference } from '../../asset/asset-reference.js';
import { Component } from '../component.js';
import { Debug } from '../../../core/debug.js';
import { GSplatPlacement } from '../../../scene/gsplat-unified/gsplat-placement.js';
/**
* @import { BoundingBox } from '../../../core/shape/bounding-box.js'
* @import { Entity } from '../../entity.js'
* @import { EventHandle } from '../../../core/event-handle.js'
* @import { GSplatComponentSystem } from './system.js'
* @import { ShaderMaterial } from '../../../scene/materials/shader-material.js'
*/ /**
* The GSplatComponent enables an {@link Entity} to render 3D Gaussian Splats. Splats are always
* loaded from {@link Asset}s rather than being created programmatically. The asset type is
* `gsplat` which supports multiple file formats including `.ply`, `.sog`, `.meta.json` (SOGS
* format), and `.lod-meta.json` (streaming LOD format).
*
* You should never need to use the GSplatComponent constructor directly. To add an
* GSplatComponent to an {@link Entity}, use {@link Entity#addComponent}:
*
* ```javascript
* const entity = pc.Entity();
* entity.addComponent('gsplat', {
* asset: asset
* });
* ```
*
* Once the GSplatComponent is added to the entity, you can access it via the {@link Entity#gsplat}
* property:
*
* ```javascript
* entity.gsplat.customAabb = new pc.BoundingBox(new pc.Vec3(), new pc.Vec3(10, 10, 10));
*
* console.log(entity.gsplat.customAabb);
* ```
*
* ## Unified Rendering
*
* The {@link GSplatComponent#unified} property enables unified rendering mode, which provides
* advanced features for Gaussian Splats:
*
* - **Global Sorting**: Multiple splat components are sorted together in a single unified sort,
* eliminating visibility artifacts and popping effects when splat components overlap.
* - **LOD Streaming**: Dynamically loads and renders appropriate levels of detail based on camera
* distance, enabling efficient rendering of massive splat scenes.
*
* ```javascript
* // Enable unified rendering for advanced features
* entity.gsplat.unified = true;
* ```
*
* Note: The `unified` property can only be changed when the component is disabled.
*
* Relevant Engine API examples:
*
* - [Simple Splat Loading](https://playcanvas.github.io/#/gaussian-splatting/simple)
* - [Global Sorting](https://playcanvas.github.io/#/gaussian-splatting/global-sorting)
* - [LOD](https://playcanvas.github.io/#/gaussian-splatting/lod)
* - [LOD Instances](https://playcanvas.github.io/#/gaussian-splatting/lod-instances)
* - [LOD Streaming](https://playcanvas.github.io/#/gaussian-splatting/lod-streaming)
* - [LOD Streaming with Spherical Harmonics](https://playcanvas.github.io/#/gaussian-splatting/lod-streaming-sh)
* - [Multi-Splat](https://playcanvas.github.io/#/gaussian-splatting/multi-splat)
* - [Multi-View](https://playcanvas.github.io/#/gaussian-splatting/multi-view)
* - [Picking](https://playcanvas.github.io/#/gaussian-splatting/picking)
* - [Reveal Effect](https://playcanvas.github.io/#/gaussian-splatting/reveal)
* - [Shader Effects](https://playcanvas.github.io/#/gaussian-splatting/shader-effects)
* - [Spherical Harmonics](https://playcanvas.github.io/#/gaussian-splatting/spherical-harmonics)
*
* @hideconstructor
* @category Graphics
*/ class GSplatComponent extends Component {
/**
* Create a new GSplatComponent.
*
* @param {GSplatComponentSystem} system - The ComponentSystem that created this Component.
* @param {Entity} entity - The Entity that this Component is attached to.
*/ constructor(system, entity){
super(system, entity), /** @private */ this._layers = [
LAYERID_WORLD
] // assign to the default world layer
, /**
* @type {GSplatInstance|null}
* @private
*/ this._instance = null, /**
* @type {GSplatPlacement|null}
* @private
*/ this._placement = null, /**
* @type {ShaderMaterial|null}
* @private
*/ this._materialTmp = null, /** @private */ this._highQualitySH = true, /**
* LOD distance thresholds, stored as a copy.
*
* @type {number[]|null}
* @private
*/ this._lodDistances = [
5,
10,
15,
20,
25,
30,
35,
40,
45,
50,
55,
60
], /**
* Target number of splats to render for this component. The system will adjust LOD levels
* bidirectionally to reach this budget. Set to 0 to disable (default).
*
* @type {number}
* @private
*/ this._splatBudget = 0, /**
* @type {BoundingBox|null}
* @private
*/ this._customAabb = null, /**
* @type {EventHandle|null}
* @private
*/ this._evtLayersChanged = null, /**
* @type {EventHandle|null}
* @private
*/ this._evtLayerAdded = null, /**
* @type {EventHandle|null}
* @private
*/ this._evtLayerRemoved = null, /** @private */ this._castShadows = false, /**
* Whether to use the unified gsplat rendering.
*
* @type {boolean}
* @private
*/ this._unified = false;
// gsplat asset reference
this._assetReference = new AssetReference('asset', this, system.app.assets, {
add: this._onGSplatAssetAdded,
load: this._onGSplatAssetLoad,
remove: this._onGSplatAssetRemove,
unload: this._onGSplatAssetUnload
}, this);
// handle events when the entity is directly (or indirectly as a child of sub-hierarchy)
// added or removed from the parent
entity.on('remove', this.onRemoveChild, this);
entity.on('removehierarchy', this.onRemoveChild, this);
entity.on('insert', this.onInsertChild, this);
entity.on('inserthierarchy', this.onInsertChild, this);
}
/**
* Sets a custom object space bounding box for visibility culling of the attached gsplat.
*
* @type {BoundingBox|null}
*/ set customAabb(value) {
this._customAabb = value;
// set it on meshInstance
this._instance?.meshInstance?.setCustomAabb(this._customAabb);
// set it on placement
if (this._placement && this._customAabb) {
this._placement.aabb = this._customAabb;
}
}
/**
* Gets the custom object space bounding box for visibility culling of the attached gsplat.
*
* @type {BoundingBox|null}
*/ get customAabb() {
return this._customAabb;
}
/**
* Sets a {@link GSplatInstance} on the component. If not set or loaded, it returns null.
*
* @type {GSplatInstance|null}
* @ignore
*/ set instance(value) {
Debug.assert(!this.unified);
// destroy existing instance
this.destroyInstance();
this._instance = value;
if (this._instance) {
// if mesh instance was created without a node, assign it here
const mi = this._instance.meshInstance;
if (!mi.node) {
mi.node = this.entity;
}
mi.castShadow = this._castShadows;
mi.setCustomAabb(this._customAabb);
if (this.enabled && this.entity.enabled) {
this.addToLayers();
}
}
}
/**
* Gets the {@link GSplatInstance} on the component.
*
* @type {GSplatInstance|null}
* @ignore
*/ get instance() {
return this._instance;
}
/**
* Sets the material used to render the gsplat.
*
* **Note:** This setter is only supported when {@link unified} is `false`. When it's true, multiple
* gsplat components share a single material per camera/layer combination. To access materials in
* unified mode, use {@link GsplatComponentSystem#getGSplatMaterial}.
*
* @param {ShaderMaterial} value - The material instance.
*/ set material(value) {
if (this.unified) {
Debug.warn('GSplatComponent#material setter is not supported when unified true. Use app.systems.gsplat.getGSplatMaterial(camera, layer) to access materials.');
return;
}
if (this._instance) {
this._instance.material = value;
} else {
this._materialTmp = value;
}
}
/**
* Gets the material used to render the gsplat.
*
* **Note:** This getter returns `null` when {@link unified} is `true`. In unified mode, materials are
* organized per camera/layer combination rather than per component. To access materials in
* unified mode, use {@link GsplatComponentSystem#getGSplatMaterial}.
*
* @type {ShaderMaterial|null}
*/ get material() {
if (this.unified) {
Debug.warnOnce('GSplatComponent#material getter returns null when unified=true. Use app.systems.gsplat.getGSplatMaterial(camera, layer) instead.');
return null;
}
return this._instance?.material ?? this._materialTmp ?? null;
}
/**
* Sets whether to use the high quality or the approximate (but fast) spherical-harmonic calculation when rendering SOGS data.
*
* The low quality approximation evaluates the scene's spherical harmonic contributions
* along the camera's Z-axis instead of using each gaussian's view vector. This results
* in gaussians being accurate at the center of the screen and becoming less accurate
* as they appear further from the center. This is a good trade-off for performance
* when rendering large SOGS datasets, especially on mobile devices.
*
* Defaults to false.
*
* @type {boolean}
*/ set highQualitySH(value) {
if (value !== this._highQualitySH) {
this._highQualitySH = value;
this._instance?.setHighQualitySH(value);
}
}
/**
* Gets whether the high quality (true) or the fast approximate (false) spherical-harmonic calculation is used when rendering SOGS data.
*
* @type {boolean}
*/ get highQualitySH() {
return this._highQualitySH;
}
/**
* Sets whether gsplat will cast shadows for lights that have shadow casting enabled. Defaults
* to false.
*
* @type {boolean}
*/ set castShadows(value) {
if (this._castShadows !== value) {
const mi = this.instance?.meshInstance;
if (mi) {
const layers = this.layers;
const scene = this.system.app.scene;
if (this._castShadows && !value) {
for(let i = 0; i < layers.length; i++){
const layer = scene.layers.getLayerById(this.layers[i]);
if (layer) {
layer.removeShadowCasters([
mi
]);
}
}
}
mi.castShadow = value;
if (!this._castShadows && value) {
for(let i = 0; i < layers.length; i++){
const layer = scene.layers.getLayerById(layers[i]);
if (layer) {
layer.addShadowCasters([
mi
]);
}
}
}
}
this._castShadows = value;
}
}
/**
* Gets whether gsplat will cast shadows for lights that have shadow casting enabled.
*
* @type {boolean}
*/ get castShadows() {
return this._castShadows;
}
/**
* Sets LOD distance thresholds used by octree-based gsplat rendering. The provided array
* is copied.
*
* @type {number[]|null}
*/ set lodDistances(value) {
this._lodDistances = Array.isArray(value) ? value.slice() : null;
if (this._placement) {
this._placement.lodDistances = this._lodDistances;
}
}
/**
* Gets a copy of LOD distance thresholds previously set, or null when not set.
*
* @type {number[]|null}
*/ get lodDistances() {
return this._lodDistances ? this._lodDistances.slice() : null;
}
/**
* Sets the target number of splats to render for this component. The system will adjust LOD
* levels bidirectionally to reach this budget:
* - When over budget: degrades quality for less important geometry
* - When under budget: upgrades quality for more important geometry
*
* This ensures optimal use of available rendering budget while prioritizing quality for
* closer/more important geometry.
*
* Set to 0 to disable the budget (default). When disabled, optimal LOD is determined purely
* by distance and configured LOD parameters.
*
* Only applies to octree-based gsplat rendering in unified mode.
*
* @type {number}
*/ set splatBudget(value) {
this._splatBudget = value;
if (this._placement) {
this._placement.splatBudget = this._splatBudget;
}
}
/**
* Gets the splat budget limit for this component.
*
* @type {number}
*/ get splatBudget() {
return this._splatBudget;
}
/**
* Sets whether to use the unified gsplat rendering. Can be changed only when the component is
* not enabled. Default is false.
*
* @type {boolean}
* @alpha
*/ set unified(value) {
if (this.enabled && this.entity.enabled) {
Debug.warn('GSplatComponent#unified can be changed only when the component is not enabled. Ignoring change.');
return;
}
this._unified = value;
this._onGSplatAssetAdded();
}
/**
* Gets whether to use the unified gsplat rendering.
*
* @type {boolean}
* @alpha
*/ get unified() {
return this._unified;
}
/**
* Sets an array of layer IDs ({@link Layer#id}) to which this gsplat should belong. Don't
* push, pop, splice or modify this array. If you want to change it, set a new one instead.
*
* @type {number[]}
*/ set layers(value) {
// remove the mesh instances from old layers
this.removeFromLayers();
// set the layer list
this._layers.length = 0;
for(let i = 0; i < value.length; i++){
this._layers[i] = value[i];
}
// don't add into layers until we're enabled
if (!this.enabled || !this.entity.enabled) {
return;
}
// add the mesh instance to new layers
this.addToLayers();
}
/**
* Gets the array of layer IDs ({@link Layer#id}) to which this gsplat belongs.
*
* @type {number[]}
*/ get layers() {
return this._layers;
}
/**
* Sets the gsplat asset for this gsplat component. Can also be an asset id.
*
* @type {Asset|number}
*/ set asset(value) {
const id = value instanceof Asset ? value.id : value;
if (this._assetReference.id === id) return;
if (this._assetReference.asset && this._assetReference.asset.resource) {
this._onGSplatAssetRemove();
}
this._assetReference.id = id;
if (this._assetReference.asset) {
this._onGSplatAssetAdded();
}
}
/**
* Gets the gsplat asset id for this gsplat component.
*
* @type {Asset|number}
*/ get asset() {
return this._assetReference.id;
}
/** @private */ destroyInstance() {
if (this._placement) {
this.removeFromLayers();
this._placement = null;
}
if (this._instance) {
this.removeFromLayers();
this._instance?.destroy();
this._instance = null;
}
}
/** @private */ addToLayers() {
if (this._placement) {
const layers = this.system.app.scene.layers;
for(let i = 0; i < this._layers.length; i++){
layers.getLayerById(this._layers[i])?.addGSplatPlacement(this._placement);
}
return;
}
const meshInstance = this.instance?.meshInstance;
if (meshInstance) {
const layers = this.system.app.scene.layers;
for(let i = 0; i < this._layers.length; i++){
layers.getLayerById(this._layers[i])?.addMeshInstances([
meshInstance
]);
}
}
}
removeFromLayers() {
if (this._placement) {
const layers = this.system.app.scene.layers;
for(let i = 0; i < this._layers.length; i++){
layers.getLayerById(this._layers[i])?.removeGSplatPlacement(this._placement);
}
return;
}
const meshInstance = this.instance?.meshInstance;
if (meshInstance) {
const layers = this.system.app.scene.layers;
for(let i = 0; i < this._layers.length; i++){
layers.getLayerById(this._layers[i])?.removeMeshInstances([
meshInstance
]);
}
}
}
/** @private */ onRemoveChild() {
this.removeFromLayers();
}
/** @private */ onInsertChild() {
if (this.enabled && this.entity.enabled) {
if (this._instance || this._placement) {
this.addToLayers();
}
}
}
onRemove() {
this.destroyInstance();
this.asset = null;
this._assetReference.id = null;
this.entity.off('remove', this.onRemoveChild, this);
this.entity.off('insert', this.onInsertChild, this);
}
onLayersChanged(oldComp, newComp) {
this.addToLayers();
oldComp.off('add', this.onLayerAdded, this);
oldComp.off('remove', this.onLayerRemoved, this);
newComp.on('add', this.onLayerAdded, this);
newComp.on('remove', this.onLayerRemoved, this);
}
onLayerAdded(layer) {
const index = this.layers.indexOf(layer.id);
if (index < 0) return;
if (this._instance) {
layer.addMeshInstances(this._instance.meshInstance);
}
Debug.assert(!this.unified);
}
onLayerRemoved(layer) {
const index = this.layers.indexOf(layer.id);
if (index < 0) return;
if (this._instance) {
layer.removeMeshInstances(this._instance.meshInstance);
}
Debug.assert(!this.unified);
}
onEnable() {
const scene = this.system.app.scene;
const layers = scene.layers;
this._evtLayersChanged = scene.on('set:layers', this.onLayersChanged, this);
if (layers) {
this._evtLayerAdded = layers.on('add', this.onLayerAdded, this);
this._evtLayerRemoved = layers.on('remove', this.onLayerRemoved, this);
}
if (this._instance || this._placement) {
this.addToLayers();
} else if (this.asset) {
this._onGSplatAssetAdded();
}
}
onDisable() {
const scene = this.system.app.scene;
const layers = scene.layers;
this._evtLayersChanged?.off();
this._evtLayersChanged = null;
if (layers) {
this._evtLayerAdded?.off();
this._evtLayerAdded = null;
this._evtLayerRemoved?.off();
this._evtLayerRemoved = null;
}
this.removeFromLayers();
}
/**
* Stop rendering this component without removing its mesh instance from the scene hierarchy.
*/ hide() {
if (this._instance) {
this._instance.meshInstance.visible = false;
}
}
/**
* Enable rendering of the component if hidden using {@link GSplatComponent#hide}.
*/ show() {
if (this._instance) {
this._instance.meshInstance.visible = true;
}
}
_onGSplatAssetAdded() {
if (!this._assetReference.asset) {
return;
}
if (this._assetReference.asset.resource) {
this._onGSplatAssetLoad();
} else if (this.enabled && this.entity.enabled) {
this.system.app.assets.load(this._assetReference.asset);
}
}
_onGSplatAssetLoad() {
// remove existing instance
this.destroyInstance();
const asset = this._assetReference.asset;
if (this.unified) {
this._placement = null;
if (asset) {
this._placement = new GSplatPlacement(asset.resource, this.entity);
this._placement.lodDistances = this._lodDistances;
this._placement.splatBudget = this._splatBudget;
// add placement to layers if component is enabled
if (this.enabled && this.entity.enabled) {
this.addToLayers();
}
}
} else {
// create new instance
if (asset) {
this.instance = new GSplatInstance(asset.resource, {
material: this._materialTmp,
highQualitySH: this._highQualitySH
});
this._materialTmp = null;
}
}
if (asset) {
this.customAabb = asset.resource.aabb.clone();
}
}
_onGSplatAssetUnload() {
// when unloading asset, only remove the instance
this.destroyInstance();
}
_onGSplatAssetRemove() {
this._onGSplatAssetUnload();
}
}
export { GSplatComponent };