Skip to content
Merged
Show file tree
Hide file tree
Changes from 28 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
b182ea3
Read EXT_textureInfo_constant_lod
eringram Dec 29, 2025
b375c97
WIP pass constant lod params via createRenderMaterial
eringram Dec 29, 2025
6e14b56
Merge branch 'master' into eringram/constant-lod-gltf
eringram Dec 30, 2025
f288ba5
Correctly pass in constant lod params
eringram Dec 31, 2025
51dc269
Clean up
eringram Dec 31, 2025
b035063
Merge branch 'master' into eringram/constant-lod-gltf
eringram Jan 5, 2026
9a0b671
WIP
eringram Jan 6, 2026
c3574e1
Merge branch 'master' into eringram/constant-lod-gltf
eringram Jan 6, 2026
a3df608
Fix cast
eringram Jan 8, 2026
ed43d97
Merge branch 'master' into eringram/constant-lod-gltf
eringram Jan 8, 2026
14fb7ea
Merge branch 'master' into eringram/constant-lod-gltf
eringram Jan 9, 2026
7066108
Normal map support
eringram Jan 9, 2026
cb38562
Unit tests
eringram Jan 9, 2026
6b39c81
extract-api
eringram Jan 9, 2026
7e9431e
rush change
eringram Jan 9, 2026
42471eb
Merge branch 'master' into eringram/constant-lod-gltf
eringram Jan 12, 2026
ff06613
Update core/frontend/src/tile/GltfReader.ts
eringram Jan 12, 2026
07c79f2
Update core/frontend/src/tile/GltfReader.ts
eringram Jan 12, 2026
ccb492c
Fix bug & add test
eringram Jan 12, 2026
f4afe48
Merge branch 'eringram/constant-lod-gltf' of https://github.com/iTwin…
eringram Jan 12, 2026
37ac2c5
Docs
eringram Jan 12, 2026
2b57be0
Merge branch 'master' into eringram/constant-lod-gltf
eringram Jan 13, 2026
8871ab8
Next version notes
eringram Jan 13, 2026
4191508
Merge branch 'master' into eringram/constant-lod-gltf
eringram Jan 13, 2026
1de9ae8
Merge branch 'master' into eringram/constant-lod-gltf
eringram Jan 14, 2026
ef62a2a
Merge branch 'master' into eringram/constant-lod-gltf
eringram Jan 14, 2026
190bad4
Merge branch 'master' into eringram/constant-lod-gltf
eringram Jan 15, 2026
1ca8352
Merge branch 'master' into eringram/constant-lod-gltf
eringram Jan 16, 2026
c07a983
Clarify limitations of extension support
eringram Jan 16, 2026
2c21970
Merge branch 'master' into eringram/constant-lod-gltf
eringram Jan 20, 2026
75640c8
Merge branch 'master' into eringram/constant-lod-gltf
eringram Jan 22, 2026
3d4bfe2
Merge branch 'master' into eringram/constant-lod-gltf
eringram Jan 23, 2026
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
4 changes: 3 additions & 1 deletion common/api/core-frontend.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -3998,7 +3998,7 @@ export abstract class GltfReader {
protected readonly _deduplicateVertices: boolean;
defaultWrapMode: GltfWrapMode;
// (undocumented)
protected findTextureMapping(id: string | undefined, isTransparent: boolean, normalMapId: string | undefined): TextureMapping | undefined;
protected findTextureMapping(id: string | undefined, isTransparent: boolean, normalMapId: string | undefined, constantLodParamProps: TextureMapping.ConstantLodParamProps | undefined, normalMapUseConstantLod?: boolean): TextureMapping | undefined;
// (undocumented)
getBufferView(json: {
[k: string]: any;
Expand Down Expand Up @@ -7042,6 +7042,8 @@ export interface MeshArgs {
textureMapping?: {
texture: RenderTexture;
uvParams: Point2d[];
useConstantLod?: boolean;
constantLodParams?: TextureMapping.ConstantLodParamProps;
};
vertIndices: number[];
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"changes": [
{
"packageName": "@itwin/core-common",
"comment": "",
"type": "none"
}
],
"packageName": "@itwin/core-common"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"changes": [
{
"packageName": "@itwin/core-frontend",
"comment": "Support EXT_textureInfo_constant_lod glTF extension",
Comment thread
markschlosseratbentley marked this conversation as resolved.
"type": "none"
}
],
"packageName": "@itwin/core-frontend"
}
66 changes: 66 additions & 0 deletions core/common/src/test/TextureMapping.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Bentley Systems, Incorporated. All rights reserved.
* See LICENSE.md in the project root for license terms and full copyright notice.
*--------------------------------------------------------------------------------------------*/
import { describe, expect, it } from "vitest";
import { TextureMapping } from "../TextureMapping";

describe("TextureMapping.Params", () => {
describe("constructor", () => {
it("applies default values when no props provided", () => {
const params = new TextureMapping.Params();

expect(params.mode).toBe(TextureMapping.Mode.Parametric);
expect(params.weight).toBe(1);
expect(params.worldMapping).toBe(false);
expect(params.useConstantLod).toBe(false);
expect(params.textureMatrix).toBe(TextureMapping.Trans2x3.identity);

// Constant LOD params should have defaults
expect(params.constantLodParams.repetitions).toBe(1);
expect(params.constantLodParams.offset).toEqual({ x: 0, y: 0 });
expect(params.constantLodParams.minDistClamp).toBe(1);
expect(params.constantLodParams.maxDistClamp).toBe(4096 * 1024 * 1024);
});

it("applies default constant LOD values when useConstantLod is true but no constantLodProps provided", () => {
const params = new TextureMapping.Params({ useConstantLod: true });

expect(params.useConstantLod).toBe(true);
expect(params.constantLodParams.repetitions).toBe(1);
expect(params.constantLodParams.offset).toEqual({ x: 0, y: 0 });
expect(params.constantLodParams.minDistClamp).toBe(1);
expect(params.constantLodParams.maxDistClamp).toBe(4096 * 1024 * 1024);
});

it("applies default constant LOD values for missing properties in constantLodProps", () => {
// Only provide repetitions, other props should use defaults
const params = new TextureMapping.Params({
useConstantLod: true,
constantLodProps: { repetitions: 5.0 },
});

expect(params.constantLodParams.repetitions).toBe(5.0);
expect(params.constantLodParams.offset).toEqual({ x: 0, y: 0 });
expect(params.constantLodParams.minDistClamp).toBe(1);
expect(params.constantLodParams.maxDistClamp).toBe(4096 * 1024 * 1024);
});

it("uses provided constant LOD values when all are specified", () => {
const params = new TextureMapping.Params({
useConstantLod: true,
constantLodProps: {
repetitions: 2.5,
offset: { x: 10, y: 20 },
minDistClamp: 100,
maxDistClamp: 5000,
},
});

expect(params.constantLodParams.repetitions).toBe(2.5);
expect(params.constantLodParams.offset).toEqual({ x: 10, y: 20 });
expect(params.constantLodParams.minDistClamp).toBe(100);
expect(params.constantLodParams.maxDistClamp).toBe(5000);
});
});
});
9 changes: 9 additions & 0 deletions core/frontend/src/common/gltf/GltfSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,15 @@ export interface GltfTextureInfo extends GltfProperty {
* Default: 0.
*/
texCoord?: number;
extensions?: GltfExtensions & {
// eslint-disable-next-line @typescript-eslint/naming-convention
EXT_textureInfo_constant_lod?: {
repetitions?: number,
offset?: [number, number],
minClampDistance?: number,
maxClampDistance?: number
}
};
}

/** Describes a texture and its sampler.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,9 @@ export function createMeshArgs(mesh: Mesh): MeshArgs | undefined {
return undefined;

const texture = mesh.displayParams.textureMapping?.texture;
const textureMapping = texture && mesh.uvParams.length > 0 ? { texture, uvParams: mesh.uvParams } : undefined;
const useConstantLod = mesh.displayParams.textureMapping?.params?.useConstantLod;
const constantLodParams = mesh.displayParams.textureMapping?.params?.constantLodParams;
const textureMapping = texture && mesh.uvParams.length > 0 ? { texture, uvParams: mesh.uvParams, useConstantLod, constantLodParams } : undefined;

const colors = new ColorIndex();
mesh.colorMap.toColorIndex(colors, mesh.colors);
Expand Down
10 changes: 9 additions & 1 deletion core/frontend/src/render/MeshArgs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* @module Rendering
*/

import { ColorIndex, FeatureIndex, FillFlags, OctEncodedNormal, QPoint3dList, RenderMaterial, RenderTexture } from "@itwin/core-common";
import { ColorIndex, FeatureIndex, FillFlags, OctEncodedNormal, QPoint3dList, RenderMaterial, RenderTexture, TextureMapping } from "@itwin/core-common";
import { MeshArgsEdges } from "../common/internal/render/MeshPrimitives";
import { AuxChannel, Point2d, Point3d, Range3d } from "@itwin/core-geometry";

Expand Down Expand Up @@ -61,6 +61,14 @@ export interface MeshArgs {
texture: RenderTexture;
/** The per-vertex texture coordinates, indexed by [[vertIndices]]. */
uvParams: Point2d[];
/** True if want to use constant LOD texture mapping for the surface texture.
* Default: false.
*/
useConstantLod?: boolean;
Comment thread
markschlosseratbentley marked this conversation as resolved.
/** Parameters for constant LOD mapping mode.
* See [[TextureMapping.ConstantLodParamProps]] for defaults.
*/
constantLodParams?: TextureMapping.ConstantLodParamProps;
Comment thread
markschlosseratbentley marked this conversation as resolved.
};
}

206 changes: 203 additions & 3 deletions core/frontend/src/test/tile/GltfReader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@ import { Range3d } from "@itwin/core-geometry";
import { EmptyLocalization, GltfV2ChunkTypes, GltfVersions, RenderTexture, TileFormat } from "@itwin/core-common";
import { IModelConnection } from "../../IModelConnection";
import { IModelApp } from "../../IModelApp";
import { GltfDataType, GltfDocument, GltfId, GltfMesh, GltfMeshMode, GltfMeshPrimitive, GltfNode, GltfSampler, GltfWrapMode } from "../../common/gltf/GltfSchema";
import { Gltf2Material, GltfDataType, GltfDocument, GltfId, GltfMesh, GltfMeshMode, GltfMeshPrimitive, GltfNode, GltfSampler, GltfWrapMode } from "../../common/gltf/GltfSchema";
import { getMeshPrimitives, GltfDataBuffer, GltfGraphicsReader, GltfReader, GltfReaderArgs, GltfReaderProps, GltfReaderResult } from "../../tile/GltfReader";
import { createBlankConnection } from "../createBlankConnection";
import { BatchedTileIdMap } from "../../tile/internal";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";

/* eslint-disable @typescript-eslint/naming-convention */

Expand Down Expand Up @@ -1506,7 +1506,6 @@ const meshFeaturesExt: GltfDocument = JSON.parse(`
},
},
}, "fallback");

});

it("if a given primitive appears more than once in the same group", () => {
Expand Down Expand Up @@ -1644,6 +1643,207 @@ const meshFeaturesExt: GltfDocument = JSON.parse(`
});
});

describe("EXT_textureInfo_constant_lod", () => {
const constantLodDoc: GltfDocument = JSON.parse(`
{
"asset": { "version": "2.0" },
"extensionsUsed": ["EXT_textureInfo_constant_lod"],
"extensionsRequired": ["EXT_textureInfo_constant_lod"],
"scene": 0,
"scenes": [{ "nodes": [0] }],
"nodes": [{ "mesh": 0 }],
"meshes": [{
"primitives": [{
"attributes": { "POSITION": 0 },
"indices": 1,
"material": 0
}]
}],
"materials": [{
"pbrMetallicRoughness": {
"baseColorTexture": {
"index": 0,
"extensions": {
"EXT_textureInfo_constant_lod": {
"repetitions": 2.5,
"offset": [10.0, 20.0],
"minClampDistance": 100.0,
"maxClampDistance": 5000.0
}
}
}
}
}],
"textures": [{ "source": 0 }],
"images": [{ "uri": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" }],
"accessors": [
{ "bufferView": 0, "componentType": 5126, "count": 3, "type": "VEC3", "min": [0, 0, 0], "max": [1, 1, 0] },
{ "bufferView": 1, "componentType": 5123, "count": 3, "type": "SCALAR" }
],
"bufferViews": [
{ "buffer": 0, "byteOffset": 0, "byteLength": 36 },
{ "buffer": 0, "byteOffset": 36, "byteLength": 6 }
],
"buffers": [{ "byteLength": 42 }]
}
`);

it("reads constant LOD properties", async () => {
// Add simple triangle vertex data
const binaryData = new Uint8Array(42);
const floatView = new Float32Array(binaryData.buffer, 0, 9);
floatView.set([0, 0, 0, 1, 0, 0, 0, 1, 0]);
const indexView = new Uint16Array(binaryData.buffer, 36, 3);
indexView.set([0, 1, 2]);

const reader = createReader(makeGlb(constantLodDoc, binaryData))!;
expect(reader).toBeDefined();

const doc = (reader as any)._glTF as GltfDocument;
expect(doc.materials).toBeDefined();

const material = doc.materials![0] as Gltf2Material;
expect(material.pbrMetallicRoughness?.baseColorTexture).toBeDefined();

const ext = material.pbrMetallicRoughness?.baseColorTexture?.extensions?.EXT_textureInfo_constant_lod;
expect(ext).toBeDefined();
expect(ext?.repetitions).toBe(2.5);
expect(ext?.offset).toEqual([10.0, 20.0]);
expect(ext?.minClampDistance).toBe(100.0);
expect(ext?.maxClampDistance).toBe(5000.0);
});

it("reads extension even when properties are omitted", async () => {
const emptyExtDoc: GltfDocument = JSON.parse(JSON.stringify(constantLodDoc));
(emptyExtDoc.materials![0] as Gltf2Material).pbrMetallicRoughness!.baseColorTexture!.extensions!.EXT_textureInfo_constant_lod = {};

const binaryData = new Uint8Array(42);
const floatView = new Float32Array(binaryData.buffer, 0, 9);
floatView.set([0, 0, 0, 1, 0, 0, 0, 1, 0]);
const indexView = new Uint16Array(binaryData.buffer, 36, 3);
indexView.set([0, 1, 2]);

const reader = createReader(makeGlb(emptyExtDoc, binaryData))!;
expect(reader).toBeDefined();

const doc = (reader as any)._glTF as GltfDocument;
const material = doc.materials![0] as Gltf2Material;
const ext = material.pbrMetallicRoughness?.baseColorTexture?.extensions?.EXT_textureInfo_constant_lod;

expect(ext).toBeDefined();

// All properties should be undefined (defaults are applied by TextureMapping.Params constructor)
expect(ext?.repetitions).toBeUndefined();
expect(ext?.offset).toBeUndefined();
expect(ext?.minClampDistance).toBeUndefined();
expect(ext?.maxClampDistance).toBeUndefined();
});

it("reads extension from emissiveTexture when baseColorTexture has no extension", async () => {
const emissiveExtDoc: GltfDocument = JSON.parse(JSON.stringify(constantLodDoc));
const material = emissiveExtDoc.materials![0] as Gltf2Material;
delete material.pbrMetallicRoughness!.baseColorTexture!.extensions;

// Add emissiveTexture with extension
material.emissiveTexture = {
index: 0,
extensions: {
EXT_textureInfo_constant_lod: {
repetitions: 3.0,
offset: [5.0, 15.0],
minClampDistance: 50.0,
maxClampDistance: 2500.0,
},
},
};

const binaryData = new Uint8Array(42);
const floatView = new Float32Array(binaryData.buffer, 0, 9);
floatView.set([0, 0, 0, 1, 0, 0, 0, 1, 0]);
const indexView = new Uint16Array(binaryData.buffer, 36, 3);
indexView.set([0, 1, 2]);

const reader = createReader(makeGlb(emissiveExtDoc, binaryData))!;
expect(reader).toBeDefined();

const doc = (reader as any)._glTF as GltfDocument;
const mat = doc.materials![0] as Gltf2Material;
expect(mat.pbrMetallicRoughness?.baseColorTexture?.extensions?.EXT_textureInfo_constant_lod).toBeUndefined();

const ext = mat.emissiveTexture?.extensions?.EXT_textureInfo_constant_lod;
expect(ext).toBeDefined();
expect(ext?.repetitions).toBe(3.0);
expect(ext?.offset).toEqual([5.0, 15.0]);
expect(ext?.minClampDistance).toBe(50.0);
expect(ext?.maxClampDistance).toBe(2500.0);
});

it("reads extension from normalTexture", async () => {
const normalExtDoc: GltfDocument = JSON.parse(JSON.stringify(constantLodDoc));
const material = normalExtDoc.materials![0] as Gltf2Material;

// Add normalTexture with extension
material.normalTexture = {
index: 0,
extensions: {
EXT_textureInfo_constant_lod: {
repetitions: 4.0,
},
},
};

const binaryData = new Uint8Array(42);
const floatView = new Float32Array(binaryData.buffer, 0, 9);
floatView.set([0, 0, 0, 1, 0, 0, 0, 1, 0]);
const indexView = new Uint16Array(binaryData.buffer, 36, 3);
indexView.set([0, 1, 2]);

const reader = createReader(makeGlb(normalExtDoc, binaryData))!;
expect(reader).toBeDefined();

const doc = (reader as any)._glTF as GltfDocument;
const mat = doc.materials![0] as Gltf2Material;

const ext = mat.normalTexture?.extensions?.EXT_textureInfo_constant_lod;
expect(ext).toBeDefined();
expect(ext?.repetitions).toBe(4.0);
});

it("does not enable constant LOD when extension is not present", async () => {
const noExtDoc: GltfDocument = JSON.parse(JSON.stringify(constantLodDoc));
const material = noExtDoc.materials![0] as Gltf2Material;

// Remove extension from baseColorTexture and document-level declarations
delete material.pbrMetallicRoughness!.baseColorTexture!.extensions;
delete noExtDoc.extensionsUsed;
delete noExtDoc.extensionsRequired;

const binaryData = new Uint8Array(42);
const floatView = new Float32Array(binaryData.buffer, 0, 9);
floatView.set([0, 0, 0, 1, 0, 0, 0, 1, 0]);
const indexView = new Uint16Array(binaryData.buffer, 36, 3);
indexView.set([0, 1, 2]);

const reader = createReader(makeGlb(noExtDoc, binaryData))!;
expect(reader).toBeDefined();

const doc = (reader as any)._glTF as GltfDocument;
const mat = doc.materials![0] as Gltf2Material;

expect(mat.pbrMetallicRoughness?.baseColorTexture?.extensions?.EXT_textureInfo_constant_lod).toBeUndefined();
expect(mat.emissiveTexture?.extensions?.EXT_textureInfo_constant_lod).toBeUndefined();

const findTextureMappingSpy = vi.spyOn(reader as any, "findTextureMapping");
(reader as any).createDisplayParams(mat, false);

// constantLodParamProps (4th argument to findTextureMapping) should be undefined when extension is not present
expect(findTextureMappingSpy).toHaveBeenCalled();
expect(findTextureMappingSpy).toHaveBeenCalledWith(expect.any(String), false, undefined, undefined, false);

vi.restoreAllMocks();
});
});

describe("Reality Tile Loader", () => {
//These tests emulate way a reader is created by the reality tile loader

Expand Down
Loading
Loading