Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
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
12 changes: 12 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# Change Log

## 1.133 - 2025-09-01

### @cesium/engine

#### Fixes :wrench:

- Materials loaded from type now respect submaterials present in the referenced material type. [#10566](https://github.com/CesiumGS/cesium/issues/10566)

#### Additions :tada:

- Adds an async factory method for the Material class that allows callers to wait on resource loading. [#10566](https://github.com/CesiumGS/cesium/issues/10566)

## 1.132 - 2025-08-01

### @cesium/engine
Expand Down
283 changes: 202 additions & 81 deletions packages/engine/Source/Scene/Material.js
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,15 @@ function Material(options) {

this._defaultTexture = undefined;

/**
* Any and all promises that are created when initializing the material.
* Examples: loading images and cubemaps.
*
* @type {Promise[]}
* @private
*/
this._initializationPromises = [];

initializeMaterial(options, this);
Object.defineProperties(this, {
type: {
Expand Down Expand Up @@ -384,6 +393,63 @@ Material.fromType = function (type, uniforms) {
return material;
};

/**
* Creates a new material using an existing material type and returns a promise that resolves when
* all of the material's resources have been loaded.
*
* @param {string} type The base material type.
* @param {object} [uniforms] Overrides for the default uniforms.
* @returns {Promise<Material>} A promise that resolves to a new material object when all resources are loaded.
*
* @exception {DeveloperError} material with that type does not exist.
*
* @example
* const material = await Cesium.Material.fromTypeAsync('Image', {
* image: '../Images/Cesium_Logo_overlay.png'
* });
*/
Material.fromTypeAsync = async function (type, uniforms) {
//>>includeStart('debug', pragmas.debug);
if (!defined(Material._materialCache.getMaterial(type))) {
throw new DeveloperError(`material with type '${type}' does not exist.`);
}
//>>includeEnd('debug');

const initializationPromises = [];
// Unlike Material.fromType, we need to specify the uniforms in the Material constructor up front,
// or else anything that needs to be async loaded won't be kicked off until the next Update call.
const material = new Material({
fabric: {
type: type,
uniforms: uniforms,
},
});

// Recursively collect initialization promises for this material and its submaterials.
getInitializationPromises(material, initializationPromises);
await Promise.all(initializationPromises);

return material;
};

/**
* Recursively traverses the material and its submaterials to collect all initialization promises.
* @param {Material} material The material to traverse.
* @param {Promise[]} initializationPromises The array to collect promises into.
*
* @private
*/
function getInitializationPromises(material, initializationPromises) {
initializationPromises.push(...material._initializationPromises);
const submaterials = material.materials;
for (const name in submaterials) {
if (submaterials.hasOwnProperty(name)) {
const submaterial = submaterials[name];
getInitializationPromises(submaterial, initializationPromises);
}
}
}

/**
* Gets whether or not this material is translucent.
* @returns {boolean} <code>true</code> if this material is translucent, <code>false</code> otherwise.
Expand Down Expand Up @@ -586,6 +652,7 @@ function initializeMaterial(options, result) {
result._strict = options.strict ?? false;
result._count = options.count ?? 0;
result._template = clone(options.fabric ?? Frozen.EMPTY_OBJECT);
result.fabric = clone(options.fabric ?? Frozen.EMPTY_OBJECT);
result._template.uniforms = clone(
result._template.uniforms ?? Frozen.EMPTY_OBJECT,
);
Expand Down Expand Up @@ -616,15 +683,15 @@ function initializeMaterial(options, result) {
// Make sure the template has no obvious errors. More error checking happens later.
checkForTemplateErrors(result);

createMethodDefinition(result);
createUniforms(result);
createSubMaterials(result);

// If the material has a new type, add it to the cache.
if (!defined(cachedMaterial)) {
Material._materialCache.addMaterial(result.type, result);
}

createMethodDefinition(result);
createUniforms(result);
createSubMaterials(result);

const defaultTranslucent =
result._translucentFunctions.length === 0 ? true : undefined;
translucent = translucent ?? defaultTranslucent;
Expand Down Expand Up @@ -858,10 +925,10 @@ function createTexture2DUpdateFunction(uniformId) {
texture.destroy();
}
texture = undefined;
material._texturePaths[uniformId] = undefined;
}

if (!defined(texture)) {
material._texturePaths[uniformId] = undefined;
texture = material._textures[uniformId] = material._defaultTexture;

uniformDimensionsName = `${uniformId}Dimensions`;
Expand All @@ -876,59 +943,89 @@ function createTexture2DUpdateFunction(uniformId) {
return;
}

// When using the entity layer, the Resource objects get recreated on getValue because
// they are clonable. That's why we check the url property for Resources
// because the instances aren't the same and we keep trying to load the same
// image if it fails to load.
const isResource = uniformValue instanceof Resource;
if (
!defined(material._texturePaths[uniformId]) ||
(isResource &&
uniformValue.url !== material._texturePaths[uniformId].url) ||
(!isResource && uniformValue !== material._texturePaths[uniformId])
) {
if (typeof uniformValue === "string" || isResource) {
const resource = isResource
? uniformValue
: Resource.createIfNeeded(uniformValue);

let promise;
if (ktx2Regex.test(resource.url)) {
promise = loadKTX2(resource.url);
} else {
promise = resource.fetchImage();
}

Promise.resolve(promise)
.then(function (image) {
material._loadedImages.push({
id: uniformId,
image: image,
});
})
.catch(function () {
if (defined(texture) && texture !== material._defaultTexture) {
texture.destroy();
}
material._textures[uniformId] = material._defaultTexture;
});
} else if (
uniformValue instanceof HTMLCanvasElement ||
(uniformValue instanceof HTMLCanvasElement ||
uniformValue instanceof HTMLImageElement ||
uniformValue instanceof ImageBitmap ||
uniformValue instanceof OffscreenCanvas
) {
material._loadedImages.push({
id: uniformId,
image: uniformValue,
});
}

uniformValue instanceof OffscreenCanvas) &&
uniformValue !== material._texturePaths[uniformId]
) {
material._loadedImages.push({
id: uniformId,
image: uniformValue,
});
material._texturePaths[uniformId] = uniformValue;
return;
}

// If we get to this point, the image should be a string URL or Resource.
// Don't wait on the promise to resolve, just start loading the image and poll status from the update loop.
loadTexture2DImageForUniform(material, uniformId);
};
}

/**
* For a given uniform ID, potentially loads a texture image for the material, if the uniform value is a Resource or string URL,
* and has changed since the last time this was called (either on construction or update).
*
* @param {Material} material The material to load the texture for.
* @param {string} uniformId The ID of the uniform of the image.
* @returns A promise that resolves when the image is loaded, or a resolved promise if image loading is not necessary.
*
* @private
*/
function loadTexture2DImageForUniform(material, uniformId) {
const uniforms = material.uniforms;
const uniformValue = uniforms[uniformId];
if (uniformValue === Material.DefaultImageId) {
return Promise.resolve();
}

// Attempt to make a resource from the uniform value. If it's not already a resource or string, this returns the original object.
const resource = Resource.createIfNeeded(uniformValue);
if (!(resource instanceof Resource)) {
return Promise.resolve();
}

// When using the entity layer, the Resource objects get recreated on getValue because
// they are clonable. That's why we check the url property for Resources
// because the instances aren't the same and we keep trying to load the same
// image if it fails to load.
const oldResource = Resource.createIfNeeded(
material._texturePaths[uniformId],
);
const uniformHasChanged =
!defined(oldResource) || oldResource.url !== resource.url;
if (!uniformHasChanged) {
return Promise.resolve();
}

let promise;
if (ktx2Regex.test(resource.url)) {
promise = loadKTX2(resource.url);
} else {
promise = resource.fetchImage();
}

Promise.resolve(promise)
.then(function (image) {
material._loadedImages.push({
id: uniformId,
image: image,
});
})
.catch(function () {
const texture = material._textures[uniformId];
if (defined(texture) && texture !== material._defaultTexture) {
texture.destroy();
}
material._textures[uniformId] = material._defaultTexture;
});

material._texturePaths[uniformId] = uniformValue;
return promise;
}

function createCubeMapUpdateFunction(uniformId) {
return function (material, context) {
const uniformValue = material.uniforms[uniformId];
Expand All @@ -944,42 +1041,60 @@ function createCubeMapUpdateFunction(uniformId) {
}

if (!defined(material._textures[uniformId])) {
material._texturePaths[uniformId] = undefined;
material._textures[uniformId] = context.defaultCubeMap;
}

if (uniformValue === Material.DefaultCubeMapId) {
return;
}
loadCubeMapImagesForUniform(material, uniformId);
};
}

const path =
uniformValue.positiveX +
uniformValue.negativeX +
uniformValue.positiveY +
uniformValue.negativeY +
uniformValue.positiveZ +
uniformValue.negativeZ;

if (path !== material._texturePaths[uniformId]) {
const promises = [
Resource.createIfNeeded(uniformValue.positiveX).fetchImage(),
Resource.createIfNeeded(uniformValue.negativeX).fetchImage(),
Resource.createIfNeeded(uniformValue.positiveY).fetchImage(),
Resource.createIfNeeded(uniformValue.negativeY).fetchImage(),
Resource.createIfNeeded(uniformValue.positiveZ).fetchImage(),
Resource.createIfNeeded(uniformValue.negativeZ).fetchImage(),
];

Promise.all(promises).then(function (images) {
material._loadedCubeMaps.push({
id: uniformId,
images: images,
});
});
/**
* Loads the images for a cubemap uniform, if it has changed since the last time this was called.
*
* @param {Material} material The material to load the cubemap images for.
* @param {string} uniformId The ID of the uniform that corresponds to the cubemap images.
* @returns A promise that resolves when the images are loaded, or a resolved promise if image loading is not necessary.
*/
function loadCubeMapImagesForUniform(material, uniformId) {
const uniforms = material.uniforms;
const uniformValue = uniforms[uniformId];
if (uniformValue === Material.DefaultCubeMapId) {
return Promise.resolve();
}

material._texturePaths[uniformId] = path;
}
};
const path =
uniformValue.positiveX +
uniformValue.negativeX +
uniformValue.positiveY +
uniformValue.negativeY +
uniformValue.positiveZ +
uniformValue.negativeZ;

// The uniform value is unchanged, no update / image load necessary.
if (path === material._texturePaths[uniformId]) {
return Promise.resolve();
}

const promises = [
Resource.createIfNeeded(uniformValue.positiveX).fetchImage(),
Resource.createIfNeeded(uniformValue.negativeX).fetchImage(),
Resource.createIfNeeded(uniformValue.positiveY).fetchImage(),
Resource.createIfNeeded(uniformValue.negativeY).fetchImage(),
Resource.createIfNeeded(uniformValue.positiveZ).fetchImage(),
Resource.createIfNeeded(uniformValue.negativeZ).fetchImage(),
];

const allPromise = Promise.all(promises);
allPromise.then(function (images) {
material._loadedCubeMaps.push({
id: uniformId,
images: images,
});
});

material._texturePaths[uniformId] = path;

return allPromise;
}

function createUniforms(material) {
Expand Down Expand Up @@ -1059,11 +1174,17 @@ function createUniform(material, uniformId) {
return material._textures[uniformId];
};
material._updateFunctions.push(createTexture2DUpdateFunction(uniformId));
material._initializationPromises.push(
loadTexture2DImageForUniform(material, uniformId),
);
} else if (uniformType === "samplerCube") {
material._uniforms[newUniformId] = function () {
return material._textures[uniformId];
};
material._updateFunctions.push(createCubeMapUpdateFunction(uniformId));
material._initializationPromises.push(
loadCubeMapImagesForUniform(material, uniformId),
);
} else if (uniformType.indexOf("mat") !== -1) {
const scratchMatrix = new matrixMap[uniformType]();
material._uniforms[newUniformId] = function () {
Expand Down
Loading