Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
7c6a2a8
clean up
keyboardspecialist Aug 1, 2025
a78ab94
SH changes
keyboardspecialist Aug 1, 2025
94165d2
Merge branch 'main' into gsplat-sh-release
keyboardspecialist Aug 26, 2025
faf0111
update changes for clarity
keyboardspecialist Aug 26, 2025
2448e21
gltf spec updates
keyboardspecialist Aug 26, 2025
f9f99aa
fix extension check
keyboardspecialist Aug 26, 2025
42e0c8d
spec update support for SH content
keyboardspecialist Aug 27, 2025
81d252b
wording
keyboardspecialist Aug 27, 2025
9026e4a
Merge branch 'main' into gsplat-sh-release
keyboardspecialist Aug 27, 2025
1d03ac0
extension spelling
keyboardspecialist Aug 27, 2025
40e4fe6
spz extension check clean up and sync
keyboardspecialist Aug 27, 2025
8ecb57c
Add spherical harmonic unit test.
keyboardspecialist Aug 28, 2025
0b099c3
sh unit test data
keyboardspecialist Aug 28, 2025
3e0ac2b
Fixed re-enabled tests
keyboardspecialist Aug 28, 2025
c3cc59c
Spherical harmonic naming clarity. Moved read only properties to priv…
keyboardspecialist Aug 28, 2025
abc8209
Merge branch 'main' into gsplat-sh-release
keyboardspecialist Aug 28, 2025
a18a935
comment cleanup
keyboardspecialist Aug 29, 2025
a0baf6c
rename gaussianSplatSHTexture sphericalHarmonicsTexture
keyboardspecialist Aug 29, 2025
b725db4
param description
keyboardspecialist Aug 29, 2025
2836299
object param case
keyboardspecialist Aug 29, 2025
38bba9a
VertexAttributeSemantic param description
keyboardspecialist Aug 29, 2025
032fb1f
renamed splatPrimitive to gltfPrimitive for clarity
keyboardspecialist Aug 29, 2025
ae0adcc
Fix bad merge
keyboardspecialist Aug 29, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
#### Additions :tada:

- Added support for the [EXT_mesh_primitive_restart](https://github.com/KhronosGroup/glTF/pull/2478) glTF extension. [#12764](https://github.com/CesiumGS/cesium/issues/12764)
- Added spherical harmonics support for Gaussian Splats. Currently supported through the SPZ compression format.

## 1.132 - 2025-08-01

Expand Down
18 changes: 2 additions & 16 deletions packages/engine/Source/Scene/Cesium3DTileContentFactory.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,28 +93,14 @@ const Cesium3DTileContentFactory = {
const dataView = new DataView(arrayBuffer, byteOffset);
const byteLength = dataView.getUint32(8, true);
const glb = new Uint8Array(arrayBuffer, byteOffset, byteLength);
let hasGaussianSplatExtension = false;
if (tileset.isGltfExtensionRequired instanceof Function) {
hasGaussianSplatExtension = tileset.isGltfExtensionRequired(
"KHR_spz_gaussian_splats_compression",
);
}
if (hasGaussianSplatExtension) {
if (GaussianSplat3DTileContent.tilesetHasGaussianSplattingExt(tileset)) {
return GaussianSplat3DTileContent.fromGltf(tileset, tile, resource, glb);
}

return Model3DTileContent.fromGltf(tileset, tile, resource, glb);
},
gltf: function (tileset, tile, resource, json) {
const forceGaussianSplats =
tileset.debugTreatTilesetAsGaussianSplats ?? false;
let hasGaussianSplatExtension = false;
if (tileset.isGltfExtensionRequired instanceof Function) {
hasGaussianSplatExtension = tileset.isGltfExtensionRequired(
"KHR_spz_gaussian_splats_compression",
);
}
if (forceGaussianSplats || hasGaussianSplatExtension) {
if (GaussianSplat3DTileContent.tilesetHasGaussianSplattingExt(tileset)) {
return GaussianSplat3DTileContent.fromGltf(tileset, tile, resource, json);
}

Expand Down
189 changes: 189 additions & 0 deletions packages/engine/Source/Scene/GaussianSplat3DTileContent.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import GaussianSplatPrimitive from "./GaussianSplatPrimitive.js";
import destroyObject from "../Core/destroyObject.js";
import ModelUtility from "./Model/ModelUtility.js";
import VertexAttributeSemantic from "./VertexAttributeSemantic.js";
import deprecationWarning from "../Core/deprecationWarning.js";

/**
* Represents the contents of a glTF or glb using the {@link https://github.com/CesiumGS/glTF/tree/draft-spz-splat-compression/extensions/2.0/Khronos/KHR_spz_gaussian_splats_compression|KHR_spz_gaussian_splats_compression} extension.
Expand Down Expand Up @@ -78,6 +79,20 @@ function GaussianSplat3DTileContent(loader, tileset, tile, resource) {
* @private
*/
this._transformed = false;

/**
* The degree of the spherical harmonics used for the Gaussian splats.
* @type {number}
* @private
*/
this.shDegree = 0;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need both degree and coefficient as independent properties? It's possible to determine one based on the other, no?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are correct it can be derived, but it's nice to not have to and keeps things more explicit. They are used in different ways when making checks and indexing.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we set one and use a getter function for the other? I'm mostly concerned about these values getting out of sync since they can both be set.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, I see now they are set privately and only exposed via the getter. Should all be fine then. 👍


/**
* The number of spherical harmonic coefficients used for the Gaussian splats.
* @type {number}
* @private
*/
this.shCoefficientCount = 0;
}

Object.defineProperties(GaussianSplat3DTileContent.prototype, {
Expand Down Expand Up @@ -311,6 +326,172 @@ Object.defineProperties(GaussianSplat3DTileContent.prototype, {
},
});

GaussianSplat3DTileContent.tilesetHasGaussianSplattingExt = function (tileset) {
let hasGaussianSplatExtension = false;
let hasLegacyGaussianSplatExtension = false;
if (tileset.isGltfExtensionRequired instanceof Function) {
hasGaussianSplatExtension =
tileset.isGltfExtensionRequired("KHR_gaussian_splatting") &&
tileset.isGltfExtensionRequired(
"KHR_gaussian_splatting_compression_spz_2",
);

hasLegacyGaussianSplatExtension = tileset.isGltfExtensionRequired(
"KHR_spz_gaussian_splats_compression",
);
}

if (hasLegacyGaussianSplatExtension) {
deprecationWarning(
"KHR_spz_gaussian_splats_compression",
"Support for the original KHR_spz_gaussian_splats_compression extension has been deprecated in favor " +
"of the up to date KHR_gaussian_splatting and KHR_gaussian_splatting_compression_spz_2 extensions and will be " +
"removed in CesiumJS 1.134.\n\nPlease retile your tileset with the KHR_gaussian_splatting and " +
"KHR_gaussian_splatting_compression_spz_2 extensions.",
);
}

return hasGaussianSplatExtension || hasLegacyGaussianSplatExtension;
};

/**
* Determine Spherical Harmonics degree and coefficient count from attributes
* @param {Array} attributes - The list of attributes.
* @returns {Object} An object containing the degree (l) and coefficient (n).
*/
function degreeAndCoefFromAttributes(attributes) {
const prefix = "_SH_DEGREE_";
const shAttributes = attributes.filter((attr) =>
attr.name.startsWith(prefix),
);

switch (shAttributes.length) {
default:
case 0:
return { l: 0, n: 0 };
case 3:
return { l: 1, n: 9 };
case 8:
return { l: 2, n: 24 };
case 15:
return { l: 3, n: 45 };
}
}

/**
* Converts a 32-bit floating point number to a 16-bit floating point number.
* @param {Float} float32 input
* @returns {number} Half precision float
* @private
*/
const buffer = new ArrayBuffer(4);
const floatView = new Float32Array(buffer);
const intView = new Uint32Array(buffer);

function float32ToFloat16(float32) {
floatView[0] = float32;
const bits = intView[0];

const sign = (bits >> 31) & 0x1;
const exponent = (bits >> 23) & 0xff;
const mantissa = bits & 0x7fffff;

let half;

if (exponent === 0xff) {
half = (sign << 15) | (0x1f << 10) | (mantissa ? 0x200 : 0);
} else if (exponent === 0) {
half = sign << 15;
} else {
const newExponent = exponent - 127 + 15;
if (newExponent >= 31) {
half = (sign << 15) | (0x1f << 10);
} else if (newExponent <= 0) {
half = sign << 15;
} else {
half = (sign << 15) | (newExponent << 10) | (mantissa >>> 13);
}
}

return half;
}

/**
* Extracts the spherical harmonic degree and coefficient from the attribute name.
* @param {String} attribute - The attribute name.
* @returns {Object} An object containing the degree (l) and coefficient (n).
* @private
*/
function extractSHDegreeAndCoef(attribute) {
const prefix = "_SH_DEGREE_";
const separator = "_COEF_";

const lStart = prefix.length;
const coefIndex = attribute.indexOf(separator, lStart);

const l = parseInt(attribute.slice(lStart, coefIndex), 10);
const n = parseInt(attribute.slice(coefIndex + separator.length), 10);

return { l, n };
}

/**
* Packs spherical harmonic data into half-precision floats.
* @param {*} data - The input data to pack.
* @param {*} shDegree - The spherical harmonic degree.
* @returns {Uint32Array} - The packed data.
*/
function packSphericalHarmonicData(tileContent) {
const degree = tileContent.shDegree;
const coefs = tileContent.shCoefficientCount;
const totalLength = tileContent.pointsLength * (coefs * (2 / 3)); //3 packs into 2
const packedData = new Uint32Array(totalLength);

const shAttributes = tileContent.splatPrimitive.attributes.filter((attr) =>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think the attributes property was properly defined in the GaussianSplatPrimitive class. Looks like it was getting set as a side-effect of a helper function in ModelUtility. Please add and document it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's defined in the GS content class. Very ambiguous, but it's a reference to the glTF primitive. I renamed it for better clarity.

attr.name.startsWith("_SH_DEGREE_"),
);
let stride = 0;
const base = [0, 9, 24];
switch (degree) {
case 1:
stride = 9;
break;
case 2:
stride = 24;
break;
case 3:
stride = 45;
break;
}
shAttributes.sort((a, b) => {
if (a.name < b.name) {
return -1;
}
if (a.name > b.name) {
return 1;
}

return 0;
});
const packedStride = stride * (2 / 3);
for (let i = 0; i < shAttributes.length; i++) {
const { l, n } = extractSHDegreeAndCoef(shAttributes[i].name);
for (let j = 0; j < tileContent.pointsLength; j++) {
//interleave the data
const packedBase = (base[l - 1] * 2) / 3;
const idx = j * packedStride + packedBase + n * 2;
const src = j * 3;
packedData[idx] =
float32ToFloat16(shAttributes[i].typedArray[src]) |
(float32ToFloat16(shAttributes[i].typedArray[src + 1]) << 16);
packedData[idx + 1] = float32ToFloat16(
shAttributes[i].typedArray[src + 2],
);
}
}
return packedData;
}

/**
* Creates a new instance of {@link GaussianSplat3DTileContent} from a glTF or glb resource.
*
Expand Down Expand Up @@ -412,6 +593,14 @@ GaussianSplat3DTileContent.prototype.update = function (primitive, frameState) {
).typedArray,
);

const { l, n } = degreeAndCoefFromAttributes(
this.splatPrimitive.attributes,
);
this.shDegree = l;
this.shCoefficientCount = n;

this._packedShData = packSphericalHarmonicData(this);

return;
}

Expand Down
Loading
Loading