From 6453d01007cad18737e459e6e7b43f575c450132 Mon Sep 17 00:00:00 2001 From: Julien Moreau-Mathis Date: Thu, 21 May 2026 14:41:07 +0200 Subject: [PATCH 01/11] feat: add support of preload per scene in database in babylonjs-editor-tools --- tools/src/index.ts | 1 + tools/src/loading/database/database.ts | 13 ++- tools/src/loading/database/preload.ts | 122 +++++++++++++++++++++++++ tools/src/tools/tools.ts | 21 +++++ 4 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 tools/src/loading/database/preload.ts diff --git a/tools/src/index.ts b/tools/src/index.ts index 23ae4033c..e19e9daa2 100644 --- a/tools/src/index.ts +++ b/tools/src/index.ts @@ -43,3 +43,4 @@ export * from "./cinematic/cinematic"; export * from "./loading/database/indexdb"; export * from "./loading/database/database"; +export * from "./loading/database/preload"; diff --git a/tools/src/loading/database/database.ts b/tools/src/loading/database/database.ts index d08ce5c45..5fb09e497 100644 --- a/tools/src/loading/database/database.ts +++ b/tools/src/loading/database/database.ts @@ -1,6 +1,7 @@ import { AbstractEngine } from "@babylonjs/core/Engines/abstractEngine"; import { IOfflineProvider } from "@babylonjs/core/Offline/IOfflineProvider"; +import { waitUntil } from "../../tools/tools"; import { ILoadFileProgressEvent, loadFile, loadJsonFile } from "../../tools/request"; import { getFromIndexDB, openIndexDB, putInIndexDB } from "./indexdb"; @@ -103,6 +104,11 @@ export class Database implements IOfflineProvider { } } + public close(): void { + this._database?.close(); + this._database = null; + } + /** * Loads an image from the offline support * @param url defines the url to load from @@ -116,6 +122,8 @@ export class Database implements IOfflineProvider { let data: any = null; + await waitUntil(() => this._manifestVersion !== null); + const version = await this._getFileVersionForUrl(url); if (version !== this._manifestVersion) { data = await loadFile(url, "blob"); @@ -160,7 +168,8 @@ export class Database implements IOfflineProvider { * @param url defines the URL to check the version for */ public async isFileMatchingVersion(url: string): Promise { - return (await this._getFileVersionForUrl(url)) === this._manifestVersion; + const version = await this._getFileVersionForUrl(url); + return version === this._manifestVersion; } /** @@ -184,6 +193,8 @@ export class Database implements IOfflineProvider { let data: any = null; + await waitUntil(() => this._manifestVersion !== null); + const version = await this._getFileVersionForUrl(url); if (version !== this._manifestVersion) { data = await loadFile(url, useArrayBuffer ? "arraybuffer" : "text", progressCallBack); diff --git a/tools/src/loading/database/preload.ts b/tools/src/loading/database/preload.ts new file mode 100644 index 000000000..0b4c11e2e --- /dev/null +++ b/tools/src/loading/database/preload.ts @@ -0,0 +1,122 @@ +import { AbstractEngine } from "@babylonjs/core/Engines/abstractEngine"; + +import { loadJsonFile } from "../../tools/request"; + +import { createAndOpenDatabase } from "./database"; + +const supportedJsonExtensions = ["babylon", "json"]; +const supportedImageExtensions = ["jpg", "jpeg", "png", "bmp", "webp"]; +const supportedBinaryExtensions = ["bin", "babylonbinarymeshdata", "mp3", "wav", "ktx", "ktx2"]; + +const allSupportedExtensions = [...supportedJsonExtensions, ...supportedImageExtensions, ...supportedBinaryExtensions]; + +type ScenesUsedFilesType = Record; + +export interface IPreloadAssetsToDatabaseOptions { + engine?: AbstractEngine; + /** + * Defines wether to disable loading and saving images to the database. + * This is particularly useful when the application supports compressed KTX textures. So only KTX textures are stored. + */ + disableImages?: boolean; + /** + * A callback function that is called with the progress of the asset loading process, as a value between 0 and 1. + * @param progress defines the progress of the asset loading process, as a value between 0 and 1. + */ + onProgress?: (progress: number) => void; +} + +/** + * Preloads the assets used by the scenes into the database. + * This can be used to preload the assets before starting the application or to update the database with new assets after a new deployment. + * This is particularly useful to improve loading times when the application with a slow network connection, as the assets will be loaded from the local database instead of being downloaded from the server each time. + * @param databaseName defines the name of the database to create (if doesn't exists) and open and save files to it + * @param rootUrl defines the root url to load the scenes used files list and the scene manifest files from. Typically "/scenes/". + * @param options defines the options for preloading assets, including a callback function to be called with the progress of the asset loading process + */ +export async function preloadAssetsToDatabase(databaseName: string, rootUrl: string, options?: IPreloadAssetsToDatabaseOptions) { + const promises: Promise[] = []; + const scenesUsedFiles = await loadJsonFile(`${rootUrl}scenes-used-files.json`); + + let loadedCount = 0; + + const supportedKtxFormat = options?.engine?.texturesSupported[0]; + const totalLength = Object.values(scenesUsedFiles).reduce((sum, files) => sum + files.length, 0); + + function notifyProgress(resolve?: () => void) { + ++loadedCount; + options?.onProgress?.(loadedCount / totalLength); + resolve?.(); + } + + for (const [sceneName, files] of Object.entries(scenesUsedFiles)) { + const babylonSceneName = sceneName.replace(".scene", ".babylon"); + + const database = await createAndOpenDatabase(databaseName, `${rootUrl}${babylonSceneName}`); + if (!database) { + continue; + } + + for (let i = 0, len = files.length; i < len; ++i) { + const file = files[i]; + const extension = file.split(".").pop()?.toLowerCase(); + if (!extension) { + notifyProgress(); + continue; + } + + if (extension === "ktx") { + if (!supportedKtxFormat) { + notifyProgress(); + continue; + } else if (!file.endsWith(supportedKtxFormat)) { + notifyProgress(); + continue; + } + } + + if (promises.length > 300) { + try { + await Promise.all(promises); + } catch (e) { + // Catch silently. + } + + promises.splice(0, promises.length); + } + + const assetUrl = `${rootUrl}${file}`; + + if (allSupportedExtensions.includes(extension)) { + promises.push( + new Promise(async (resolve) => { + if (await database.isFileMatchingVersion(assetUrl)) { + return notifyProgress(resolve); + } + + try { + if (supportedImageExtensions.includes(extension)) { + if (!options?.disableImages) { + await database.saveImage(assetUrl); + } + } else if (supportedBinaryExtensions.includes(extension)) { + await database.saveFile(assetUrl, true); + } else if (supportedJsonExtensions.includes(extension)) { + await database.saveFile(assetUrl, false); + } + } catch (e) { + // Catch silently. + } + + notifyProgress(resolve); + }) + ); + } + } + + await Promise.all(promises); + database.close(); + } + + await Promise.all(promises); +} diff --git a/tools/src/tools/tools.ts b/tools/src/tools/tools.ts index 325cc917d..1e7b39532 100644 --- a/tools/src/tools/tools.ts +++ b/tools/src/tools/tools.ts @@ -1,3 +1,24 @@ +/** + * Wait for a given amount of time expressed in milliseconds. + * @param timeMs The time to wait in milliseconds. + * @returns A promise that resolves after the given amount of time. + */ +export function waitMs(timeMs: number): Promise { + return new Promise((resolve) => { + setTimeout(() => resolve(), timeMs); + }); +} + +/** + * Wait until a given predicate returns true. + * @param predicate Defines the predicate to wait for. + */ +export async function waitUntil(predicate: () => any): Promise { + while (!predicate()) { + await waitMs(150); + } +} + /** * Clones the given JavaScript object. This function does not handle cyclic references. * @param source defines the reference to the JavaScript object to clone. From 42de961f8c6cd55ee6ebd636c63f42ace459bdab Mon Sep 17 00:00:00 2001 From: Julien Moreau-Mathis Date: Fri, 22 May 2026 16:16:59 +0200 Subject: [PATCH 02/11] feat: add support of decals merge to reduce draw calls when possible --- cli/src/pack/assets/collect.mts | 15 +++- cli/src/pack/geometry.mts | 3 +- cli/src/pack/pack.mts | 5 +- cli/src/pack/scene.mts | 49 ++++++++++--- editor/src/project/save/decals.ts | 110 +++++++++++++++++++++++++++++ editor/src/project/save/scene.ts | 17 ++++- editor/src/tools/node/parenting.ts | 18 +++++ 7 files changed, 200 insertions(+), 17 deletions(-) create mode 100644 editor/src/project/save/decals.ts diff --git a/cli/src/pack/assets/collect.mts b/cli/src/pack/assets/collect.mts index 8f6a962af..fe34d5915 100644 --- a/cli/src/pack/assets/collect.mts +++ b/cli/src/pack/assets/collect.mts @@ -5,7 +5,8 @@ import { pathExists } from "fs-extra"; import { supportedExtensions } from "./process.mjs"; import { allKtxFormats, getCompressedTextureFilename, ktxSupportedextensions } from "./ktx.mjs"; -const collectedSupportedExtensions: string[] = [...supportedExtensions, ".babylonbinarymeshdata"]; +const binaryGeometryExtension = ".babylonbinarymeshdata"; +const collectedSupportedExtensions: string[] = [...supportedExtensions, binaryGeometryExtension]; export async function collectUsedAssetsForScene(scene: any, publicDir: string) { const result: string[] = []; @@ -25,7 +26,11 @@ export async function collectUsedAssetsForScene(scene: any, publicDir: string) { stringValues.push(value); } } else if (typeof value === "object") { - recursivelyCollect(value); + if (Array.isArray(value)) { + value.forEach((v) => recursivelyCollect(v)); + } else { + recursivelyCollect(value); + } } } } @@ -34,11 +39,15 @@ export async function collectUsedAssetsForScene(scene: any, publicDir: string) { await Promise.all( stringValues.map(async (value) => { + const extension = extname(value).toLowerCase(); + if (extension === binaryGeometryExtension) { + return result.push(value); + } + const absolutePath = join(publicDir, value); if (await pathExists(absolutePath)) { result.push(value); - const extension = extname(value).toLowerCase(); if (ktxSupportedextensions.includes(extension)) { for (const format of allKtxFormats) { const compressedTexturePath = join(publicDir, getCompressedTextureFilename(value, format)); diff --git a/cli/src/pack/geometry.mts b/cli/src/pack/geometry.mts index f0adc49aa..6370c5f56 100644 --- a/cli/src/pack/geometry.mts +++ b/cli/src/pack/geometry.mts @@ -8,6 +8,7 @@ export interface ICreateGeometryFilesOptions { sceneFile: string; sceneName: string; publicDir: string; + geometryFiles: string[]; exportedAssets: string[]; babylonjsEditorToolsVersion: string; @@ -18,7 +19,7 @@ export async function createGeometryFiles(options: ICreateGeometryFilesOptions) await fs.ensureDir(join(options.publicDir, options.sceneName)); await Promise.all( - options.directories.geometryFiles.map(async (file) => { + options.geometryFiles.map(async (file) => { const destination = join(options.publicDir, options.sceneName, file); await fs.copyFile(join(options.sceneFile, "geometries", file), destination); options.exportedAssets.push(destination); diff --git a/cli/src/pack/pack.mts b/cli/src/pack/pack.mts index 9bc086802..a7367577c 100644 --- a/cli/src/pack/pack.mts +++ b/cli/src/pack/pack.mts @@ -161,7 +161,7 @@ export async function pack(projectDir: string, options: IPackOptions) { message: `Packing scene ${sceneName}...`, }); - const sceneUsedFiles = await createBabylonScene({ + const sceneFiles = await createBabylonScene({ ...options, ...projectConfiguration, config, @@ -173,7 +173,7 @@ export async function pack(projectDir: string, options: IPackOptions) { babylonjsEditorToolsVersion, }); - scenesUsedFiles[`${sceneName}.scene`] = sceneUsedFiles.map((asset) => asset.replace(publicDir + "/", "")); + scenesUsedFiles[`${sceneName}.scene`] = sceneFiles.usedFiles.map((asset) => asset.replace(publicDir + "/", "")); options.onStepChanged?.("scenes", { message: `Packing scene ${sceneName} geometries...`, @@ -181,6 +181,7 @@ export async function pack(projectDir: string, options: IPackOptions) { // Copy geometry files await createGeometryFiles({ + ...sceneFiles, directories, publicDir, sceneFile, diff --git a/cli/src/pack/scene.mts b/cli/src/pack/scene.mts index f412700f6..2dd5c13bd 100644 --- a/cli/src/pack/scene.mts +++ b/cli/src/pack/scene.mts @@ -1,6 +1,6 @@ -import { join, basename } from "node:path/posix"; +import { join, basename, extname } from "node:path/posix"; -import fs from "fs-extra"; +import fs, { pathExists } from "fs-extra"; import { readSceneDirectories } from "../tools/scene.mjs"; @@ -35,6 +35,23 @@ export async function createBabylonScene(options: ICreateBabylonSceneOptions) { const shadowGenerators: any[] = []; const morphTargetManagers: any[] = []; + // Merged decals + let mergedDecalsIds: any[] = []; + + const mergedDecalsPath = join(options.sceneFile, "decals.json"); + if (await pathExists(mergedDecalsPath)) { + const mergedDecals = await fs.readJSON(mergedDecalsPath, { + encoding: "utf-8", + }); + + mergedDecals.forEach((mesh) => { + mesh.delayLoadingFile = join(options.sceneName, basename(mesh.delayLoadingFile)); + meshes.push(mesh); + }); + + mergedDecalsIds = mergedDecals.map((m) => m.metadata?.mergedMeshesIds ?? []).flat(); + } + // Meshes const meshesResult = await Promise.all( options.directories.meshesFiles.map(async (file) => { @@ -123,6 +140,13 @@ export async function createBabylonScene(options: ICreateBabylonSceneOptions) { }); } + if (mesh.metadata?.decal && mergedDecalsIds.includes(mesh.id)) { + return { + lodMeshes, + effectiveMaterials, + }; + } + return { mesh, lodMeshes, @@ -132,16 +156,16 @@ export async function createBabylonScene(options: ICreateBabylonSceneOptions) { ); meshesResult.forEach((result) => { - if (result) { + if (result?.mesh) { meshes.push(result.mesh); + } - result.lodMeshes.forEach((lodMesh) => { - meshes.push(lodMesh); - }); + result?.lodMeshes.forEach((lodMesh) => { + meshes.push(lodMesh); + }); - if (result.effectiveMaterials.length) { - materials.push(...result.effectiveMaterials); - } + if (result?.effectiveMaterials.length) { + materials.push(...result.effectiveMaterials); } }); @@ -623,5 +647,10 @@ export async function createBabylonScene(options: ICreateBabylonSceneOptions) { const usedFiles = await collectUsedAssetsForScene(scene, options.publicDir); usedFiles.push(`${options.sceneName}.babylon`); - return usedFiles; + const geometryFiles = usedFiles.filter((file) => extname(file).toLowerCase() === ".babylonbinarymeshdata").map((file) => basename(file)); + + return { + usedFiles, + geometryFiles, + }; } diff --git a/editor/src/project/save/decals.ts b/editor/src/project/save/decals.ts new file mode 100644 index 000000000..48be8cb19 --- /dev/null +++ b/editor/src/project/save/decals.ts @@ -0,0 +1,110 @@ +import { join } from "path/posix"; +import { writeJSON } from "fs-extra"; + +import { Mesh, Tools, SceneSerializer } from "babylonjs"; + +import { UniqueNumber } from "../../tools/tools"; +import { isMesh } from "../../tools/guards/nodes"; +import { writeBinaryGeometry } from "../tools/geometry"; +import { isNodeFromStaticGroup } from "../../tools/node/parenting"; + +import { Editor } from "../../editor/main"; + +export interface ISavedMergedDecalsOptions { + scenePath: string; + savedFiles: string[]; + relativeScenePath: string; +} + +export async function saveMergedDecals(editor: Editor, options: ISavedMergedDecalsOptions) { + const scene = editor.layout.preview.scene; + + const decalsMap = new Map(); + + scene.meshes.forEach((mesh) => { + if (mesh.metadata.scripts?.length || !isNodeFromStaticGroup(mesh)) { + return; + } + + if (mesh.metadata.decal && mesh.material && mesh.material !== scene.defaultMaterial && isMesh(mesh)) { + if (mesh.metadata.scripts) { + return; + } + + const array = decalsMap.get(mesh.material.id) ?? []; + array.push(mesh); + decalsMap.set(mesh.material.id, array); + } + }); + + const meshData = await Promise.all( + decalsMap.values().map(async (array) => { + if (array.length < 2) { + return null; + } + + try { + const mergedMesh = (await Mesh.MergeMeshesAsync(array, false, true, undefined, false, undefined)) as Mesh; + mergedMesh.metadata = { + mergedMeshesIds: array.map((mesh) => mesh.id), + }; + + mergedMesh.id = Tools.RandomId(); + mergedMesh.uniqueId = UniqueNumber.Get(); + mergedMesh.material = array[0].material; + mergedMesh.isPickable = false; + mergedMesh.receiveShadows = true; + + const data = await SceneSerializer.SerializeMesh(mergedMesh, false, false); + + const mesh = data.meshes[0]; + const geometry = data.geometries?.vertexData?.find((v) => v.id === mesh.geometryId); + + if (!geometry) { + return editor.layout.console.warn(`Failed to merge decals "${array[0].name}": geometry not found.`); + } + + const geometryFileName = `${mergedMesh.id}_merged_decals.babylonbinarymeshdata`; + + mesh.delayLoadingFile = join(options.relativeScenePath, `geometries/${geometryFileName}`); + mesh.boundingBoxMaximum = mergedMesh.getBoundingInfo()?.maximum?.asArray() ?? [0, 0, 0]; + mesh.boundingBoxMinimum = mergedMesh.getBoundingInfo()?.minimum?.asArray() ?? [0, 0, 0]; + mesh._binaryInfo = {}; + + const path = join(options.scenePath, "geometries", geometryFileName); + + await writeBinaryGeometry({ + path, + mesh, + geometry, + write: true, + }); + + mergedMesh.dispose(false, false); + + options.savedFiles.push(path); + + return mesh; + } catch (e) { + editor.layout.console.error(`Failed to merge decals "${array[0].name}":`); + editor.layout.console.error(e.message); + } + + return null; + }) + ); + + if (meshData.length) { + const decalsJsonPath = join(options.scenePath, "decals.json"); + + await writeJSON( + decalsJsonPath, + meshData.filter((mesh) => mesh !== null), + { + encoding: "utf-8", + } + ); + + options.savedFiles.push(decalsJsonPath); + } +} diff --git a/editor/src/project/save/scene.ts b/editor/src/project/save/scene.ts index 7667fff1f..2faf4a9b3 100644 --- a/editor/src/project/save/scene.ts +++ b/editor/src/project/save/scene.ts @@ -1,5 +1,5 @@ import { join, basename } from "path/posix"; -import { readJSON, remove, stat, writeFile, writeJSON } from "fs-extra"; +import { pathExists, readJSON, remove, stat, writeFile, writeJSON } from "fs-extra"; import filenamify from "filenamify"; @@ -33,6 +33,7 @@ import { iblShadowsRenderingPipelineCameraConfigurations } from "../../editor/re import { writeBinaryGeometry } from "../tools/geometry"; import { writeBinaryMorphTarget } from "../tools/morph-target"; +import { saveMergedDecals } from "./decals"; import { showSaveSceneProgressDialog } from "./dialog"; export function ensureSceneFolders(scenePath: string) { @@ -791,6 +792,20 @@ export async function saveScene(editor: Editor, projectPath: string, scenePath: savedFiles.push(configPath); } + // Don't remove attributes if exist + const attributesPath = join(scenePath, "attributes.json"); + if (await pathExists(attributesPath)) { + savedFiles.push(attributesPath); + } + + // Merge all decals + dialog.setName("Merging decals"); + await saveMergedDecals(editor, { + scenePath, + savedFiles, + relativeScenePath, + }); + // Remove old files const files = await normalizedGlob(join(scenePath, "/**"), { nodir: true, diff --git a/editor/src/tools/node/parenting.ts b/editor/src/tools/node/parenting.ts index 5e0a78e0a..b8e00cadb 100644 --- a/editor/src/tools/node/parenting.ts +++ b/editor/src/tools/node/parenting.ts @@ -75,3 +75,21 @@ export function applyTransformNodeParentingConfiguration(node: Node, newParent: node.position.copyFrom(tempTransfromNode.position); } } + +/** + * Returns wether or not the given node is a descendant of a transform node set as static group. + * @param node defines the reference to the node to check the ancestors. + */ +export function isNodeFromStaticGroup(node: Node) { + let parent: Node | null = node; + + while (parent) { + if (parent.metadata?.isStaticGroup) { + return true; + } + + parent = parent.parent; + } + + return false; +} From b4cb720435524cd01bf93853c5d407c4446fd01d Mon Sep 17 00:00:00 2001 From: Cesharpe Date: Fri, 22 May 2026 21:23:05 +0200 Subject: [PATCH 03/11] v5.4.1-rc.8 --- cli/package.json | 2 +- editor/package.json | 2 +- tools/package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cli/package.json b/cli/package.json index 2e39e7d05..03b68e394 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "babylonjs-editor-cli", - "version": "5.4.1-rc.7", + "version": "5.4.1-rc.8", "description": "Babylon.js Editor CLI is a command line interface to help you package your scenes made using the Babylon.js Editor", "productName": "Babylon.js Editor CLI", "scripts": { diff --git a/editor/package.json b/editor/package.json index 6fce5143b..3ac4518d8 100644 --- a/editor/package.json +++ b/editor/package.json @@ -1,6 +1,6 @@ { "name": "babylonjs-editor", - "version": "5.4.1-rc.7", + "version": "5.4.1-rc.8", "description": "Babylon.js Editor is a Web Application helping artists to work with Babylon.js", "productName": "Babylon.js Editor", "main": "build/src/index.js", diff --git a/tools/package.json b/tools/package.json index e91676410..f46231f97 100644 --- a/tools/package.json +++ b/tools/package.json @@ -1,6 +1,6 @@ { "name": "babylonjs-editor-tools", - "version": "5.4.1-rc.7", + "version": "5.4.1-rc.8", "description": "Babylon.js Editor Tools is a set of tools to help you create, edit and manage your Babylon.js scenes made using the Babylon.js Editor", "productName": "Babylon.js Editor Tools", "scripts": { From 02e6aefaf27c5b7e8555adf3eec84f3a89ecc3ce Mon Sep 17 00:00:00 2001 From: julien-moreau Date: Sat, 23 May 2026 15:56:51 +0200 Subject: [PATCH 04/11] fix: remove duplicates and compute KTX textures for used files per scene in babylonjs-editor-cli --- cli/src/pack/assets/collect.mts | 84 +++++++++++++++++++++++++++++---- cli/src/pack/assets/process.mts | 10 ++-- cli/src/pack/assets/texture.mts | 12 ++--- cli/src/tools/array.mts | 11 +++++ 4 files changed, 98 insertions(+), 19 deletions(-) create mode 100644 cli/src/tools/array.mts diff --git a/cli/src/pack/assets/collect.mts b/cli/src/pack/assets/collect.mts index fe34d5915..773ff97c9 100644 --- a/cli/src/pack/assets/collect.mts +++ b/cli/src/pack/assets/collect.mts @@ -1,16 +1,32 @@ import { extname, join } from "node:path/posix"; +import sharp from "sharp"; import { pathExists } from "fs-extra"; -import { supportedExtensions } from "./process.mjs"; +import { unique } from "../../tools/array.mjs"; +import { getPowerOfTwoUntil } from "../../tools/scalar.mjs"; + +import { DownscaledTextureSize } from "./texture.mjs"; +import { supportedExtensions, supportedImagesExtensions } from "./process.mjs"; import { allKtxFormats, getCompressedTextureFilename, ktxSupportedextensions } from "./ktx.mjs"; const binaryGeometryExtension = ".babylonbinarymeshdata"; const collectedSupportedExtensions: string[] = [...supportedExtensions, binaryGeometryExtension]; +async function _checkKtxSupportForTexture(value: string, publicDir: string, result: string[]) { + for (const format of allKtxFormats) { + const compressedTexturePath = join(publicDir, getCompressedTextureFilename(value, format)); + const finalName = getCompressedTextureFilename(value, format); + + if (!result.includes(finalName) && (await pathExists(compressedTexturePath))) { + result.push(finalName); + } + } +} + export async function collectUsedAssetsForScene(scene: any, publicDir: string) { const result: string[] = []; - const stringValues: string[] = []; + let stringValues: string[] = []; function recursivelyCollect(root: any) { for (const thing in root) { @@ -37,6 +53,8 @@ export async function collectUsedAssetsForScene(scene: any, publicDir: string) { recursivelyCollect(scene); + stringValues = unique(stringValues); + await Promise.all( stringValues.map(async (value) => { const extension = extname(value).toLowerCase(); @@ -46,15 +64,65 @@ export async function collectUsedAssetsForScene(scene: any, publicDir: string) { const absolutePath = join(publicDir, value); if (await pathExists(absolutePath)) { - result.push(value); + if (!result.includes(value)) { + result.push(value); + } if (ktxSupportedextensions.includes(extension)) { - for (const format of allKtxFormats) { - const compressedTexturePath = join(publicDir, getCompressedTextureFilename(value, format)); - if (await pathExists(compressedTexturePath)) { - result.push(getCompressedTextureFilename(value, format)); - } + await _checkKtxSupportForTexture(value, publicDir, result); + } + + if (supportedImagesExtensions.includes(extension)) { + const sharpImage = sharp(absolutePath); + const { width, height } = await sharpImage.metadata(); + if (!width || !height) { + return; + } + + const availableSizes: DownscaledTextureSize[] = []; + const isPowerOfTwo = width === getPowerOfTwoUntil(width) || height === getPowerOfTwoUntil(height); + + let midWidth = (width * 0.66) >> 0; + let midHeight = (height * 0.66) >> 0; + + if (isPowerOfTwo) { + midWidth = getPowerOfTwoUntil(midWidth); + midHeight = getPowerOfTwoUntil(midHeight); } + + availableSizes.push({ + width: midWidth, + height: midHeight, + }); + + let lowWidth = (width * 0.33) >> 0; + let lowHeight = (height * 0.33) >> 0; + + if (isPowerOfTwo) { + lowWidth = getPowerOfTwoUntil(lowWidth); + lowHeight = getPowerOfTwoUntil(lowHeight); + } + + availableSizes.push({ + width: lowWidth, + height: lowHeight, + }); + + await Promise.all( + availableSizes.map(async (size) => { + const nameWithoutExtension = value.substring(0, value.lastIndexOf(".")); + const finalName = `${nameWithoutExtension}_${size.width}_${size.height}${extension}`; + const finalPath = join(publicDir, finalName); + + if (!result.includes(finalName) && (await pathExists(finalPath))) { + result.push(finalName); + } + + if (ktxSupportedextensions.includes(extension)) { + await _checkKtxSupportForTexture(finalName, publicDir, result); + } + }) + ); } } }) diff --git a/cli/src/pack/assets/process.mts b/cli/src/pack/assets/process.mts index 66e2cb00e..69193e648 100644 --- a/cli/src/pack/assets/process.mts +++ b/cli/src/pack/assets/process.mts @@ -8,11 +8,11 @@ import { processExportedTexture } from "./texture.mjs"; import { processExportedMaterial } from "./material.mjs"; import { processExportedNodeParticleSystemSet } from "./particle-system.mjs"; -const supportedImagesExtensions: string[] = [".jpg", ".jpeg", ".webp", ".png", ".bmp"]; -const supportedCubeTexturesExtensions: string[] = [".env", ".dds", ".hdr"]; -const supportedAudioExtensions: string[] = [".mp3", ".wav", ".wave", ".ogg"]; -const supportedJsonExtensions: string[] = [".material", ".gui", ".cinematic", ".npss", ".ragdoll", ".json"]; -const supportedMiscExtensions: string[] = [".3dl", ".exr", ".hdr"]; +export const supportedImagesExtensions: string[] = [".jpg", ".jpeg", ".webp", ".png", ".bmp"]; +export const supportedCubeTexturesExtensions: string[] = [".env", ".dds", ".hdr"]; +export const supportedAudioExtensions: string[] = [".mp3", ".wav", ".wave", ".ogg"]; +export const supportedJsonExtensions: string[] = [".material", ".gui", ".cinematic", ".npss", ".ragdoll", ".json"]; +export const supportedMiscExtensions: string[] = [".3dl", ".exr", ".hdr"]; export const supportedExtensions: string[] = [ ...supportedImagesExtensions, diff --git a/cli/src/pack/assets/texture.mts b/cli/src/pack/assets/texture.mts index 91a62d0a2..d2e7878b0 100644 --- a/cli/src/pack/assets/texture.mts +++ b/cli/src/pack/assets/texture.mts @@ -12,6 +12,11 @@ export function getExtractedTextureOutputPath(publicDir: string) { return join(publicDir, "assets", "editor-generated_extracted-textures"); } +export type DownscaledTextureSize = { + width: number; + height: number; +}; + export interface IComputeExportedTextureOptions extends IProcessAssetFileOptions { force: boolean; exportedAssets: string[]; @@ -31,12 +36,7 @@ export async function processExportedTexture(absolutePath: string, options: ICom const isPowerOfTwo = width === getPowerOfTwoUntil(width) || height === getPowerOfTwoUntil(height); - type _DownscaledTextureSize = { - width: number; - height: number; - }; - - const availableSizes: _DownscaledTextureSize[] = []; + const availableSizes: DownscaledTextureSize[] = []; let midWidth = (width * 0.66) >> 0; let midHeight = (height * 0.66) >> 0; diff --git a/cli/src/tools/array.mts b/cli/src/tools/array.mts new file mode 100644 index 000000000..4667b33d9 --- /dev/null +++ b/cli/src/tools/array.mts @@ -0,0 +1,11 @@ +/** + * Returns a new array composed of distinct elements. + * @param array defines the reference to the source array. + */ +export function unique(array: T[]): T[] { + const unique = (value: T, index: number, self: T[]) => { + return self.indexOf(value) === index; + }; + + return array.filter(unique); +} From 4af6eb0426e9e0dbfc25318d6b7d6b68c9aa34d6 Mon Sep 17 00:00:00 2001 From: Julien Moreau-Mathis Date: Mon, 25 May 2026 15:01:30 +0200 Subject: [PATCH 05/11] fix: reuse same geometries and remove duplicated binarymeshdata in babylonjs-editor-cli --- cli/src/pack/assets/collect.mts | 30 ++++++++++++++++ cli/src/pack/pack.mts | 14 ++++++-- cli/src/pack/scene.mts | 64 +++++++++++++++++++++++++++++---- cli/src/tools/array.mts | 17 +++++++++ 4 files changed, 117 insertions(+), 8 deletions(-) diff --git a/cli/src/pack/assets/collect.mts b/cli/src/pack/assets/collect.mts index 773ff97c9..6a08207e1 100644 --- a/cli/src/pack/assets/collect.mts +++ b/cli/src/pack/assets/collect.mts @@ -13,6 +13,36 @@ import { allKtxFormats, getCompressedTextureFilename, ktxSupportedextensions } f const binaryGeometryExtension = ".babylonbinarymeshdata"; const collectedSupportedExtensions: string[] = [...supportedExtensions, binaryGeometryExtension]; +export function traverseAndReplaceInSceneObject(scene: any, callback: (key: string, value: string) => string | undefined) { + function recursivelyCollect(root: any) { + for (const thing in root) { + if (!root.hasOwnProperty(thing)) { + continue; + } + + const value = root[thing]; + + if (typeof value === "string") { + const extension = extname(value).toLowerCase(); + if (extension && collectedSupportedExtensions.includes(extension)) { + const newValue = callback(thing, value); + if (newValue !== undefined) { + root[thing] = newValue; + } + } + } else if (typeof value === "object") { + if (Array.isArray(value)) { + value.forEach((v) => recursivelyCollect(v)); + } else { + recursivelyCollect(value); + } + } + } + } + + recursivelyCollect(scene); +} + async function _checkKtxSupportForTexture(value: string, publicDir: string, result: string[]) { for (const format of allKtxFormats) { const compressedTexturePath = join(publicDir, getCompressedTextureFilename(value, format)); diff --git a/cli/src/pack/pack.mts b/cli/src/pack/pack.mts index a7367577c..4b7d49ebb 100644 --- a/cli/src/pack/pack.mts +++ b/cli/src/pack/pack.mts @@ -2,6 +2,7 @@ import fs, { pathExists } from "fs-extra"; import { basename, extname, join } from "node:path/posix"; import ora from "ora"; +import chalk from "chalk"; import cliSpinners from "cli-spinners"; import { CancellationToken } from "../tools/cancel.mjs"; @@ -113,6 +114,8 @@ export async function pack(projectDir: string, options: IPackOptions) { } // Pack scenes + let totalReusedGeometriesCount = 0; + const scenesUsedFiles: Record = {}; const sceneFiles = await normalizedGlob(`${assetsDirectory}/**/*`, { @@ -173,6 +176,8 @@ export async function pack(projectDir: string, options: IPackOptions) { babylonjsEditorToolsVersion, }); + totalReusedGeometriesCount += sceneFiles.reusedGeometriesCount; + scenesUsedFiles[`${sceneName}.scene`] = sceneFiles.usedFiles.map((asset) => asset.replace(publicDir + "/", "")); options.onStepChanged?.("scenes", { @@ -230,7 +235,7 @@ export async function pack(projectDir: string, options: IPackOptions) { exportedAssets.map((asset) => asset.replace(publicDir + "/", "")), { spaces: "\t", - encoding: "utf-8", + // encoding: "utf-8", } ); @@ -239,7 +244,7 @@ export async function pack(projectDir: string, options: IPackOptions) { const scenesUsedFilesPath = join(publicDir, "scenes-used-files.json"); await fs.writeJSON(scenesUsedFilesPath, scenesUsedFiles, { spaces: "\t", - encoding: "utf-8", + // encoding: "utf-8", }); exportedAssets.push(scenesUsedFilesPath); @@ -256,5 +261,10 @@ export async function pack(projectDir: string, options: IPackOptions) { } }); } + + // Performance + if (totalReusedGeometriesCount > 0) { + console.log(chalk.green(`Total reused geometries: ${totalReusedGeometriesCount}`)); + } } } diff --git a/cli/src/pack/scene.mts b/cli/src/pack/scene.mts index 2dd5c13bd..849f3f039 100644 --- a/cli/src/pack/scene.mts +++ b/cli/src/pack/scene.mts @@ -2,12 +2,13 @@ import { join, basename, extname } from "node:path/posix"; import fs, { pathExists } from "fs-extra"; +import { isSameArray } from "../tools/array.mjs"; import { readSceneDirectories } from "../tools/scene.mjs"; import { compressFileToKtx } from "./assets/ktx.mjs"; -import { collectUsedAssetsForScene } from "./assets/collect.mjs"; import { extractNodeMaterialTextures } from "./assets/material.mjs"; import { getExtractedTextureOutputPath } from "./assets/texture.mjs"; +import { collectUsedAssetsForScene, traverseAndReplaceInSceneObject } from "./assets/collect.mjs"; import { extractNodeParticleSystemSetTextures, extractParticleSystemTextures } from "./assets/particle-system.mjs"; export interface ICreateBabylonSceneOptions { @@ -611,6 +612,61 @@ export async function createBabylonScene(options: ICreateBabylonSceneOptions) { scene.environmentTexture = scene.environmentTexture.name; } + // Manage usedfiles + const usedFiles = await collectUsedAssetsForScene(scene, options.publicDir); + usedFiles.push(`${options.sceneName}.babylon`); + + const geometryFiles = usedFiles.filter((file) => extname(file).toLowerCase() === ".babylonbinarymeshdata").map((file) => basename(file)); + + // Reuse geometries with same data + const geometries = await Promise.all( + geometryFiles.map(async (file) => { + const data = await fs.readFile(join(options.sceneFile, "geometries", file)); + return { + file, + data, + }; + }) + ); + + const sameArrays: Record = {}; + + for (let i = 0, iLen = geometries.length; i < iLen; ++i) { + for (let j = i + 1, jLen = geometries.length; j < jLen; ++j) { + if (isSameArray(geometries[i].data, geometries[j].data)) { + if (!sameArrays[geometries[i].file]) { + sameArrays[geometries[i].file] = []; + } + + sameArrays[geometries[i].file].push(join(options.sceneName, geometries[j].file)); + } + } + } + + let reusedGeometriesCount = 0; + + for (const sourceGeometryId of Object.keys(sameArrays)) { + const otherGeometries = sameArrays[sourceGeometryId]; + + traverseAndReplaceInSceneObject(scene, (_, value) => { + if (otherGeometries.includes(value)) { + const usedFilesIndex = usedFiles.indexOf(value); + if (usedFilesIndex !== -1) { + usedFiles.splice(usedFilesIndex, 1); + } + + const geometryFilesIndex = geometryFiles.indexOf(basename(value)); + if (geometryFilesIndex !== -1) { + geometryFiles.splice(geometryFilesIndex, 1); + } + + ++reusedGeometriesCount; + + return join(options.sceneName, sourceGeometryId); + } + }); + } + // Write final scene file. const destination = join(options.publicDir, `${options.sceneName}.babylon`); await fs.writeJSON(destination, scene, { @@ -644,13 +700,9 @@ export async function createBabylonScene(options: ICreateBabylonSceneOptions) { options.exportedAssets.push(manifestDestination); } - const usedFiles = await collectUsedAssetsForScene(scene, options.publicDir); - usedFiles.push(`${options.sceneName}.babylon`); - - const geometryFiles = usedFiles.filter((file) => extname(file).toLowerCase() === ".babylonbinarymeshdata").map((file) => basename(file)); - return { usedFiles, geometryFiles, + reusedGeometriesCount, }; } diff --git a/cli/src/tools/array.mts b/cli/src/tools/array.mts index 4667b33d9..7de988d66 100644 --- a/cli/src/tools/array.mts +++ b/cli/src/tools/array.mts @@ -9,3 +9,20 @@ export function unique(array: T[]): T[] { return array.filter(unique); } + +/** + * Compares two arrays and returns true if they are the same (same length and same elements in the same order). + */ +export function isSameArray(a: T[] | Buffer, b: T[] | Buffer): boolean { + if (a.length !== b.length) { + return false; + } + + for (let i = 0, len = a.length; i < len; ++i) { + if (a[i] !== b[i]) { + return false; + } + } + + return true; +} From f5aedc4dac5c36f37558d2517cb58cc3bbe91349 Mon Sep 17 00:00:00 2001 From: Julien Moreau-Mathis Date: Tue, 26 May 2026 14:31:52 +0200 Subject: [PATCH 06/11] feat: hide icons when not visible or has a blocker in the preview panel to avoid too much icons --- editor/src/editor/layout/preview.tsx | 12 +- editor/src/editor/layout/preview/icons.tsx | 199 ++++++++++++--------- 2 files changed, 125 insertions(+), 86 deletions(-) diff --git a/editor/src/editor/layout/preview.tsx b/editor/src/editor/layout/preview.tsx index c23dec8e4..806abaf3d 100644 --- a/editor/src/editor/layout/preview.tsx +++ b/editor/src/editor/layout/preview.tsx @@ -303,6 +303,10 @@ export class EditorPreview extends Component this._decalMeshPredicate(m), false); - const meshPick = this.scene.pick(x, y, (m) => this._meshPredicate(m), false); + const decalPick = this.scene.pick(x, y, (m) => this._pickingDecalMeshPredicate(m), false); + const meshPick = this.scene.pick(x, y, (m) => this._pickingMeshPredicate(m), false); const spritePick = this.scene.pickSprite(x, y, (s) => isSprite(s), false); this._lastPickedDecal = null; diff --git a/editor/src/editor/layout/preview/icons.tsx b/editor/src/editor/layout/preview/icons.tsx index 8e707124d..1d5e82762 100644 --- a/editor/src/editor/layout/preview/icons.tsx +++ b/editor/src/editor/layout/preview/icons.tsx @@ -3,14 +3,19 @@ import { Component, ReactNode } from "react"; import { HiSpeakerWave } from "react-icons/hi2"; import { FaCamera, FaLightbulb } from "react-icons/fa"; -import { Mesh, Node, Scene, Vector2, Vector3 } from "babylonjs"; +import { Mesh, Node, Scene, Vector3, Ray } from "babylonjs"; import { Editor } from "../../main"; import { isSoundNode } from "../../../tools/guards/sound"; import { isNodeLocked } from "../../../tools/node/metadata"; import { projectVectorOnScreen } from "../../../tools/maths/projection"; -import { isCamera, isClusteredLightContainer, isEditorCamera, isLight, isNode } from "../../../tools/guards/nodes"; +import { isCamera, isEditorCamera, isLight, isNode } from "../../../tools/guards/nodes"; + +interface _IButtonData { + node: Node; + absolutePosition: Vector3; +} export interface IEditorPreviewIconsProps { editor: Editor; @@ -20,15 +25,12 @@ export interface IEditorPreviewIconsState { buttons: _IButtonData[]; } -interface _IButtonData { - node: Node; - position: Vector2; -} - export class EditorPreviewIcons extends Component { private _tempMesh: Mesh | null = null; private _renderFunction: (() => void) | null = null; + private _iconsRefs: (HTMLDivElement | null)[] = []; + public constructor(props: IEditorPreviewIconsProps) { super(props); @@ -40,42 +42,31 @@ export class EditorPreviewIcons extends Component