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/cli/src/pack/assets/collect.mts b/cli/src/pack/assets/collect.mts index 8f6a962af..6a08207e1 100644 --- a/cli/src/pack/assets/collect.mts +++ b/cli/src/pack/assets/collect.mts @@ -1,15 +1,62 @@ 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 collectedSupportedExtensions: string[] = [...supportedExtensions, ".babylonbinarymeshdata"]; +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)); + 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) { @@ -25,27 +72,87 @@ 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); + } } } } recursivelyCollect(scene); + stringValues = unique(stringValues); + 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); + if (!result.includes(value)) { + result.push(value); + } - const extension = extname(value).toLowerCase(); 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/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..26f17ceef 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"; @@ -33,6 +34,8 @@ export interface IPackOptions { export async function pack(projectDir: string, options: IPackOptions) { projectDir = getProjectDir(projectDir); + const buildTime = Date.now(); + // Load project configuration const projectFiles = await fs.readdir(projectDir); const projectConfigurationFile = projectFiles.find((file) => extname(file).toLowerCase() === ".bjseditor"); @@ -113,6 +116,8 @@ export async function pack(projectDir: string, options: IPackOptions) { } // Pack scenes + let totalReusedGeometriesCount = 0; + const scenesUsedFiles: Record = {}; const sceneFiles = await normalizedGlob(`${assetsDirectory}/**/*`, { @@ -161,7 +166,7 @@ export async function pack(projectDir: string, options: IPackOptions) { message: `Packing scene ${sceneName}...`, }); - const sceneUsedFiles = await createBabylonScene({ + const sceneFiles = await createBabylonScene({ ...options, ...projectConfiguration, config, @@ -169,11 +174,14 @@ export async function pack(projectDir: string, options: IPackOptions) { publicDir, sceneFile, sceneName, + buildTime, exportedAssets, babylonjsEditorToolsVersion, }); - scenesUsedFiles[`${sceneName}.scene`] = sceneUsedFiles.map((asset) => asset.replace(publicDir + "/", "")); + totalReusedGeometriesCount += sceneFiles.reusedGeometriesCount; + + scenesUsedFiles[`${sceneName}.scene`] = sceneFiles.usedFiles.map((asset) => asset.replace(publicDir + "/", "")); options.onStepChanged?.("scenes", { message: `Packing scene ${sceneName} geometries...`, @@ -181,6 +189,7 @@ export async function pack(projectDir: string, options: IPackOptions) { // Copy geometry files await createGeometryFiles({ + ...sceneFiles, directories, publicDir, sceneFile, @@ -229,7 +238,7 @@ export async function pack(projectDir: string, options: IPackOptions) { exportedAssets.map((asset) => asset.replace(publicDir + "/", "")), { spaces: "\t", - encoding: "utf-8", + // encoding: "utf-8", } ); @@ -238,7 +247,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); @@ -255,5 +264,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 f412700f6..59fba1abc 100644 --- a/cli/src/pack/scene.mts +++ b/cli/src/pack/scene.mts @@ -1,16 +1,18 @@ -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 { 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 { + buildTime: number; sceneFile: string; sceneName: string; publicDir: string; @@ -35,6 +37,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 +142,13 @@ export async function createBabylonScene(options: ICreateBabylonSceneOptions) { }); } + if (mesh.metadata?.decal && mergedDecalsIds.includes(mesh.id)) { + return { + lodMeshes, + effectiveMaterials, + }; + } + return { mesh, lodMeshes, @@ -132,16 +158,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); } }); @@ -587,6 +613,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, { @@ -601,17 +682,16 @@ export async function createBabylonScene(options: ICreateBabylonSceneOptions) { const manifestDestination = join(options.publicDir, `${options.sceneName}.babylon.manifest`); let manifest = { - version: 0, + version: options.buildTime, enableSceneOffline: true, enableTexturesOffline: true, }; if (await fs.pathExists(manifestDestination)) { manifest = await fs.readJSON(manifestDestination); + manifest.version = options.buildTime; } - ++manifest.version; - await fs.writeJSON(manifestDestination, manifest, { encoding: "utf-8", // spaces: "\t", // Useful for debug @@ -620,8 +700,9 @@ export async function createBabylonScene(options: ICreateBabylonSceneOptions) { options.exportedAssets.push(manifestDestination); } - const usedFiles = await collectUsedAssetsForScene(scene, options.publicDir); - usedFiles.push(`${options.sceneName}.babylon`); - - return usedFiles; + return { + usedFiles, + geometryFiles, + reusedGeometriesCount, + }; } diff --git a/cli/src/tools/array.mts b/cli/src/tools/array.mts new file mode 100644 index 000000000..7de988d66 --- /dev/null +++ b/cli/src/tools/array.mts @@ -0,0 +1,28 @@ +/** + * 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); +} + +/** + * 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; +} diff --git a/editor/package.json b/editor/package.json index 6fce5143b..784f85e3b 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", @@ -40,8 +40,8 @@ "vitest": "4.0.17" }, "dependencies": { - "@babylonjs/addons": "9.4.1", - "@babylonjs/core": "9.4.1", + "@babylonjs/addons": "9.9.1", + "@babylonjs/core": "9.9.1", "@babylonjs/havok": "1.3.12", "@blueprintjs/core": "^5.10.0", "@blueprintjs/select": "^5.1.2", @@ -75,18 +75,18 @@ "@xterm/xterm": "6.1.0-beta.22", "assimpjs": "0.0.10", "axios": "1.15.2", - "babylonjs": "9.4.1", - "babylonjs-addons": "9.4.1", + "babylonjs": "9.9.1", + "babylonjs-addons": "9.9.1", "babylonjs-editor-cli": "latest", "babylonjs-editor-tools": "latest", - "babylonjs-gui": "9.4.1", - "babylonjs-gui-editor": "9.4.1", - "babylonjs-loaders": "9.4.1", - "babylonjs-materials": "9.4.1", - "babylonjs-node-editor": "9.4.1", - "babylonjs-node-particle-editor": "9.4.1", - "babylonjs-post-process": "9.4.1", - "babylonjs-procedural-textures": "9.4.1", + "babylonjs-gui": "9.9.1", + "babylonjs-gui-editor": "9.9.1", + "babylonjs-loaders": "9.9.1", + "babylonjs-materials": "9.9.1", + "babylonjs-node-editor": "9.9.1", + "babylonjs-node-particle-editor": "9.9.1", + "babylonjs-post-process": "9.9.1", + "babylonjs-procedural-textures": "9.9.1", "chokidar": "^4.0.3", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", diff --git a/editor/src/editor/layout/preview.tsx b/editor/src/editor/layout/preview.tsx index c23dec8e4..05ed4cb8f 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; @@ -995,7 +999,7 @@ export class EditorPreview extends Component this.forceUpdate()}> - Render options + Options (this.axis.enabled ? this.axis.stop() : this.axis.start())}> {this.axis?.enabled && } Axis Helper @@ -1026,7 +1030,7 @@ export class EditorPreview extends Component} Particles enabled - Renderer dimensions + Dimensions this.setFixedDimensions("720p")}> {this.state.fixedDimensions === "720p" && } 720p @@ -1040,6 +1044,22 @@ export class EditorPreview extends Component this.setFixedDimensions("fit")}> {this.state.fixedDimensions === "fit" && } Fit + + Scaling + + this.engine?.setHardwareScalingLevel(2)}> + {this.engine?.getHardwareScalingLevel() === 2 && } 50% + + this.engine?.setHardwareScalingLevel(1)}> + {this.engine?.getHardwareScalingLevel() === 1 && } 100% + + this.engine?.setHardwareScalingLevel(0.5)}> + {this.engine?.getHardwareScalingLevel() === 0.5 && } 200% + + this.engine?.setHardwareScalingLevel(1 / devicePixelRatio)}> + {this.engine?.getHardwareScalingLevel() === 1 / devicePixelRatio && } Default ({(devicePixelRatio * 100).toFixed(0)} + %) + diff --git a/editor/src/editor/layout/preview/axis.tsx b/editor/src/editor/layout/preview/axis.tsx index a6348c8ad..2e8afe488 100644 --- a/editor/src/editor/layout/preview/axis.tsx +++ b/editor/src/editor/layout/preview/axis.tsx @@ -130,7 +130,6 @@ export class EditorPreviewAxisHelper extends Component { @@ -139,15 +138,16 @@ export class EditorPreviewAxisHelper extends Component { private _tempMesh: Mesh | null = null; private _renderFunction: (() => void) | null = null; + private _iconsRefs: (HTMLDivElement | null)[] = []; + + private _cameraViewMatrixObserver: Observer | null = null; + public constructor(props: IEditorPreviewIconsProps) { super(props); @@ -40,42 +44,32 @@ export class EditorPreviewIcons extends Component