playcanvas
Version:
Open-source WebGL/WebGPU 3D engine for the web
785 lines (784 loc) • 25.6 kB
JavaScript
var __defProp = Object.defineProperty;
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
import { hashCode } from "../../../core/hash.js";
import { LAYERID_WORLD, WORKBUFFER_UPDATE_AUTO } 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 { PickerId } from "../../../scene/picker-id.js";
const UNIFIED_LEGACY_HINT = "GSplatComponent#unified now defaults to true (unified rendering). To temporarily restore the deprecated legacy behavior, explicitly set unified=false when creating the component \u2014 note that non-unified mode will be removed in a future release.";
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 */
__publicField(this, "_layers", [LAYERID_WORLD]);
// assign to the default world layer
/**
* @type {GSplatInstance|null}
* @private
*/
__publicField(this, "_instance", null);
/**
* @type {GSplatPlacement|null}
* @private
*/
__publicField(this, "_placement", null);
/**
* Unique identifier for this component, used by the picking system.
*
* @type {number}
* @private
*/
__publicField(this, "_id", PickerId.get());
/**
* @type {ShaderMaterial|null}
* @private
*/
__publicField(this, "_materialTmp", null);
/** @private */
__publicField(this, "_highQualitySH", true);
/**
* Base distance for the first LOD transition (LOD 0 to LOD 1).
*
* @private
*/
__publicField(this, "_lodBaseDistance", 5);
/**
* Geometric multiplier between successive LOD distance thresholds.
*
* @private
*/
__publicField(this, "_lodMultiplier", 3);
/**
* @type {BoundingBox|null}
* @private
*/
__publicField(this, "_customAabb", null);
/**
* @type {AssetReference}
* @private
*/
__publicField(this, "_assetReference");
/**
* Direct resource reference (for container splats).
*
* @type {GSplatResourceBase|null}
* @private
*/
__publicField(this, "_resource", null);
/**
* @type {EventHandle|null}
* @private
*/
__publicField(this, "_evtLayersChanged", null);
/**
* @type {EventHandle|null}
* @private
*/
__publicField(this, "_evtLayerAdded", null);
/**
* @type {EventHandle|null}
* @private
*/
__publicField(this, "_evtLayerRemoved", null);
/** @private */
__publicField(this, "_castShadows", false);
/**
* Whether to use the unified gsplat rendering. Defaults to true.
*
* @private
*/
__publicField(this, "_unified", true);
/**
* Per-instance shader parameters. Stores objects with scopeId and data.
*
* @type {Map<string, {scopeId: ScopeId, data: *}>}
* @private
*/
__publicField(this, "_parameters", /* @__PURE__ */ new Map());
/**
* Render mode for work buffer updates.
*
* @type {number}
* @private
*/
__publicField(this, "_workBufferUpdate", WORKBUFFER_UPDATE_AUTO);
/**
* Custom shader modify code for this component (object with code and pre-computed hash).
*
* @type {{ code: string, hash: number }|null}
* @private
*/
__publicField(this, "_workBufferModifier", null);
this._assetReference = new AssetReference(
"asset",
this,
system.app.assets,
{
add: this._onGSplatAssetAdded,
load: this._onGSplatAssetLoad,
remove: this._onGSplatAssetRemove,
unload: this._onGSplatAssetUnload
},
this
);
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;
this._instance?.meshInstance?.setCustomAabb(this._customAabb);
if (this._placement) {
this._placement.aabb = this._customAabb;
}
}
/**
* Gets the custom object space bounding box for visibility culling of the attached gsplat.
* Returns the custom AABB if set, otherwise falls back to the resource's AABB.
*
* @type {BoundingBox|null}
*/
get customAabb() {
return this._customAabb ?? this._placement?.aabb ?? this.resource?.aabb ?? null;
}
/**
* Sets a {@link GSplatInstance} on the component. If not set or loaded, it returns null.
*
* @type {GSplatInstance|null}
* @ignore
*/
set instance(value) {
if (this.unified) {
Debug.errorOnce(`GSplatComponent#instance setter is only available in legacy non-unified mode. ${UNIFIED_LEGACY_HINT}`);
return;
}
this.destroyInstance();
this._instance = value;
if (this._instance) {
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() {
if (this.unified) {
Debug.warnOnce(`GSplatComponent#instance getter returns null in unified mode. ${UNIFIED_LEGACY_HINT}`);
}
return this._instance;
}
set material(value) {
if (this.unified) {
Debug.warn(`GSplatComponent#material setter is only available in legacy non-unified mode; in unified mode use app.systems.gsplat.getMaterial(camera, layer). ${UNIFIED_LEGACY_HINT}`);
return;
}
if (this._instance) {
this._instance.material = value;
} else {
this._materialTmp = value;
}
}
get material() {
if (this.unified) {
Debug.warnOnce(`GSplatComponent#material getter returns null in unified mode; use app.systems.gsplat.getMaterial(camera, layer) instead. ${UNIFIED_LEGACY_HINT}`);
return null;
}
return this._instance?.material ?? this._materialTmp ?? null;
}
set highQualitySH(value) {
if (value !== this._highQualitySH) {
this._highQualitySH = value;
this._instance?.setHighQualitySH(value);
}
}
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 layers = this.layers;
const scene = this.system.app.scene;
if (this._placement) {
if (value) {
for (let i = 0; i < layers.length; i++) {
const layer = scene.layers.getLayerById(layers[i]);
layer?.addGSplatShadowCaster(this._placement);
}
} else {
for (let i = 0; i < layers.length; i++) {
const layer = scene.layers.getLayerById(layers[i]);
layer?.removeGSplatShadowCaster(this._placement);
}
}
}
const mi = this._instance?.meshInstance;
if (mi) {
if (this._castShadows && !value) {
for (let i = 0; i < layers.length; i++) {
const layer = scene.layers.getLayerById(this.layers[i]);
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]);
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 the base distance for the first LOD transition (LOD 0 to LOD 1). Objects closer
* than this distance use the highest quality LOD. Each subsequent LOD level transitions
* at a progressively larger distance, controlled by {@link lodMultiplier}. Clamped to a
* minimum of 0.1. Defaults to 5.
*
* @type {number}
*/
set lodBaseDistance(value) {
this._lodBaseDistance = Math.max(0.1, value);
if (this._placement) {
this._placement.lodBaseDistance = this._lodBaseDistance;
}
}
/**
* Gets the base distance for the first LOD transition.
*
* @type {number}
*/
get lodBaseDistance() {
return this._lodBaseDistance;
}
/**
* Sets the multiplier between successive LOD distance thresholds. Each LOD level
* transitions at this factor times the previous level's distance, creating a geometric
* progression. Lower values keep higher quality at distance; higher values switch to
* coarser LODs sooner. Clamped to a minimum of 1.2 to avoid degenerate logarithmic LOD
* computation. LOD distances are automatically compensated for the camera's field of
* view — a wider FOV makes objects appear smaller on screen, so LOD switches to coarser
* levels sooner to match the reduced screen-space detail. Defaults to 3.
*
* @type {number}
*/
set lodMultiplier(value) {
this._lodMultiplier = Math.max(1.2, value);
if (this._placement) {
this._placement.lodMultiplier = this._lodMultiplier;
}
}
/**
* Gets the geometric multiplier between successive LOD distance thresholds.
*
* @type {number}
*/
get lodMultiplier() {
return this._lodMultiplier;
}
/**
* @type {number[]|null}
* @deprecated Use {@link lodBaseDistance} and {@link lodMultiplier} instead.
* @ignore
*/
set lodDistances(value) {
Debug.removed("GSplatComponent#lodDistances is removed. Use lodBaseDistance and lodMultiplier instead.");
if (Array.isArray(value) && value.length > 0) {
this.lodBaseDistance = value[0];
this.lodMultiplier = 3;
}
}
/**
* @type {number[]}
* @deprecated Use {@link lodBaseDistance} and {@link lodMultiplier} instead.
* @ignore
*/
get lodDistances() {
Debug.removed("GSplatComponent#lodDistances is removed. Use lodBaseDistance and lodMultiplier instead.");
return [];
}
/**
* @type {number}
* @deprecated Use app.scene.gsplat.splatBudget instead for global budget control.
* @ignore
*/
set splatBudget(value) {
Debug.removed("GSplatComponent.splatBudget is removed. Use app.scene.gsplat.splatBudget instead for global budget control.");
}
/**
* @type {number}
* @deprecated Use app.scene.gsplat.splatBudget instead for global budget control.
* @ignore
*/
get splatBudget() {
Debug.removed("GSplatComponent.splatBudget is removed. Use app.scene.gsplat.splatBudget instead for global budget control.");
return 0;
}
/**
* Sets whether to use the unified gsplat rendering.
*
* @type {boolean}
* @deprecated Non-unified gsplat rendering is being removed; unified rendering will be the only supported mode.
* @ignore
*/
set unified(value) {
if (value === false) {
Debug.deprecated("GSplatComponent#unified is deprecated. Non-unified gsplat rendering will be removed in a future release; please migrate to unified rendering (the new default).");
}
if (this._unified !== value) {
this._unified = value;
this._onGSplatAssetAdded();
}
}
/**
* Gets whether to use the unified gsplat rendering.
*
* @type {boolean}
* @deprecated Non-unified gsplat rendering is being removed; unified rendering will be the only supported mode.
* @ignore
*/
get unified() {
return this._unified;
}
/**
* Gets the unique identifier for this component. This ID is used by the picking system
* and is also written to the work buffer when `app.scene.gsplat.enableIds` is enabled, making
* it available to custom shaders for effects like highlighting or animation.
*
* @type {number}
*/
get id() {
return this._id;
}
/**
* Sets the work buffer update mode.
*
* Splat data is rendered to a work buffer only when needed (e.g., when transforms change).
* Can be:
* - {@link WORKBUFFER_UPDATE_AUTO}: Update only when needed (default).
* - {@link WORKBUFFER_UPDATE_ONCE}: Force update this frame, then switch to AUTO.
* - {@link WORKBUFFER_UPDATE_ALWAYS}: Update every frame.
*
* This is typically useful when using custom shader code via {@link setWorkBufferModifier}
* that depends on external factors like time or animated uniforms.
*
* Note: {@link WORKBUFFER_UPDATE_ALWAYS} has a performance impact as it re-renders
* all splat data to the work buffer every frame. Where possible, consider using shader
* customization on the gsplat material (`app.scene.gsplat.material`) which is applied
* during final rendering without re-rendering the work buffer.
*
* @type {number}
*/
set workBufferUpdate(value) {
this._workBufferUpdate = value;
if (this._placement) {
this._placement.workBufferUpdate = value;
}
}
/**
* Gets the work buffer update mode.
*
* @type {number}
*/
get workBufferUpdate() {
return this._workBufferUpdate;
}
/**
* Sets custom shader code for modifying splats when written to the work buffer.
*
* Must provide all three functions:
* - `modifySplatCenter`: Modify the splat center position
* - `modifySplatRotationScale`: Modify the splat rotation and scale
* - `modifySplatColor`: Modify the splat color
*
* Calling this method automatically triggers a work buffer re-render.
*
* @param {{ glsl?: string, wgsl?: string }|null} value - The modifier code for GLSL and/or WGSL.
* @example
* entity.gsplat.setWorkBufferModifier({
* glsl: `
* void modifySplatCenter(inout vec3 center) {}
* void modifySplatRotationScale(vec3 originalCenter, vec3 modifiedCenter, inout vec4 rotation, inout vec3 scale) {}
* void modifySplatColor(vec3 center, inout vec4 color) { color.rgb *= vec3(1.0, 0.0, 0.0); }
* `,
* wgsl: `
* fn modifySplatCenter(center: ptr<function, vec3f>) {}
* fn modifySplatRotationScale(originalCenter: vec3f, modifiedCenter: vec3f, rotation: ptr<function, vec4f>, scale: ptr<function, vec3f>) {}
* fn modifySplatColor(center: vec3f, color: ptr<function, vec4f>) { (*color).r = 1.0; (*color).g = 0.0; (*color).b = 0.0; }
* `
* });
*/
setWorkBufferModifier(value) {
if (value) {
const device = this.system.app.graphicsDevice;
const code = (device.isWebGPU ? value.wgsl : value.glsl) ?? null;
this._workBufferModifier = code ? { code, hash: hashCode(code) } : null;
} else {
this._workBufferModifier = null;
}
if (this._placement) {
this._placement.workBufferModifier = this._workBufferModifier;
}
}
/**
* 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) {
this.removeFromLayers();
this._layers.length = 0;
for (let i = 0; i < value.length; i++) {
this._layers[i] = value[i];
}
if (!this.enabled || !this.entity.enabled) {
return;
}
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;
}
/**
* Sets a GSplat resource directly (for procedural/container splats).
* When set, this takes precedence over the asset property.
*
* @type {GSplatResourceBase|null}
*/
set resource(value) {
if (this._resource === value) return;
if (this._resource || this._assetReference.asset?.resource) {
this._onGSplatAssetRemove();
}
if (value && this._assetReference.id) {
this._assetReference.id = null;
}
this._resource = value;
if (this._resource && this.enabled && this.entity.enabled) {
this._onGSplatAssetLoad();
}
}
/**
* Gets the GSplat resource. Returns the directly set resource if available,
* otherwise returns the resource from the assigned asset.
*
* @type {GSplatResourceBase|null}
*/
get resource() {
return this._resource ?? this._assetReference.asset?.resource ?? null;
}
/** @private */
destroyInstance() {
if (this._placement) {
this.removeFromLayers();
this._placement.destroy();
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++) {
const layer = layers.getLayerById(this._layers[i]);
if (layer) {
layer.addGSplatPlacement(this._placement);
if (this._castShadows) {
layer.addGSplatShadowCaster(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++) {
const layer = layers.getLayerById(this._layers[i]);
if (layer) {
layer.removeGSplatPlacement(this._placement);
layer.removeGSplatShadowCaster(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.unified) return;
if (this._instance) {
layer.addMeshInstances(this._instance.meshInstance);
}
}
onLayerRemoved(layer) {
const index = this.layers.indexOf(layer.id);
if (index < 0) return;
if (this.unified) return;
if (this._instance) {
layer.removeMeshInstances(this._instance.meshInstance);
}
}
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();
} else if (this._resource) {
this._onGSplatAssetLoad();
}
}
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 hide}.
*/
show() {
if (this._instance) {
this._instance.meshInstance.visible = true;
}
}
/**
* Sets a shader parameter for this gsplat instance. Parameters set here are applied
* during rendering.
*
* @param {string} name - The name of the parameter (uniform name in shader).
* @param {number|number[]|ArrayBufferView|Texture|StorageBuffer} data - The value for the parameter.
*/
setParameter(name, data) {
const scopeId = this.system.app.graphicsDevice.scope.resolve(name);
this._parameters.set(name, { scopeId, data });
if (this._placement) this._placement.renderDirty = true;
}
/**
* Gets a shader parameter value previously set with {@link setParameter}.
*
* @param {string} name - The name of the parameter.
* @returns {number|number[]|ArrayBufferView|undefined} The parameter value, or undefined if not set.
*/
getParameter(name) {
return this._parameters.get(name)?.data;
}
/**
* Deletes a shader parameter previously set with {@link setParameter}.
*
* @param {string} name - The name of the parameter to delete.
*/
deleteParameter(name) {
this._parameters.delete(name);
if (this._placement) this._placement.renderDirty = true;
}
/**
* Gets an instance texture by name. Instance textures are per-component textures defined
* in the resource's format with `storage: GSPLAT_STREAM_INSTANCE`.
*
* @param {string} name - The name of the texture.
* @returns {Texture|null} The texture, or null if not found.
* @example
* // Add an instance stream to the resource format
* resource.format.addExtraStreams([
* { name: 'instanceTint', format: pc.PIXELFORMAT_RGBA8, storage: pc.GSPLAT_STREAM_INSTANCE }
* ]);
*
* // Get the instance texture and fill it with data
* const texture = entity.gsplat.getInstanceTexture('instanceTint');
* if (texture) {
* const data = texture.lock();
* // Fill texture data...
* texture.unlock();
* }
*/
getInstanceTexture(name) {
if (!this._placement) {
return null;
}
return this._placement.getInstanceTexture(name, this.system.app.graphicsDevice) ?? null;
}
_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() {
this.destroyInstance();
const resource = this._resource ?? this._assetReference.asset?.resource;
if (!resource) return;
if (this.unified) {
this._placement = null;
this._placement = new GSplatPlacement(resource, this.entity, 0, this._parameters, null, this._id);
this._placement.lodBaseDistance = this._lodBaseDistance;
this._placement.lodMultiplier = this._lodMultiplier;
this._placement.workBufferUpdate = this._workBufferUpdate;
this._placement.workBufferModifier = this._workBufferModifier;
if (this.enabled && this.entity.enabled) {
this.addToLayers();
}
} else {
this.instance = new GSplatInstance(resource, {
material: this._materialTmp,
highQualitySH: this._highQualitySH,
scene: this.system.app.scene
});
this._materialTmp = null;
}
}
_onGSplatAssetUnload() {
this.destroyInstance();
}
_onGSplatAssetRemove() {
this._onGSplatAssetUnload();
}
}
export {
GSplatComponent
};