Fix editor regressions and add Blender scene and animated material import

This commit is contained in:
emil28092005
2026-09-12 00:53:20 +03:00
parent 1a0bf725fa
commit 6f497d1977
34 changed files with 1241 additions and 213 deletions
+67 -61
View File
@@ -1,5 +1,5 @@
import * as B from "@babylonjs/core";
import "@babylonjs/loaders/glTF";
import "./gltf-compat.ts";
import {
type Project,
type Entity,
@@ -13,6 +13,7 @@ import {
import { workerSource } from "./script-host.ts";
import { inspectModel } from "./model.ts";
import { CharacterMotor } from "./character.ts";
import { bindViewInput } from "./view-input.ts";
import { RuntimePresentation } from "./presentation.ts";
export interface RuntimeCallbacks {
select?: (id: string | null) => void;
@@ -108,6 +109,7 @@ export class FormaRuntime {
public callbacks: RuntimeCallbacks = {},
public options: RuntimeOptions = {},
) {
const runtime = this;
this.engine =
options.engine ||
new B.Engine(
@@ -121,6 +123,12 @@ export class FormaRuntime {
false,
);
if (!options.headless) {
this.cleanup.push(bindViewInput(canvas, {
get playing() { return runtime.playing; },
get paused() { return runtime.paused; },
firstPerson: () => this.firstPerson(),
lookBy: (x, y) => this.lookBy(x, y),
}));
const resize = new ResizeObserver(() => this.engine.resize());
resize.observe(canvas);
this.cleanup.push(() => resize.disconnect());
@@ -233,6 +241,7 @@ export class FormaRuntime {
this.actionQueue.add(name);
}
releaseInput() {
if (!this.options.headless && document.pointerLockElement === this.canvas) document.exitPointerLock();
this.keys.clear();
this.actionQueue.clear();
this.automation = null;
@@ -331,7 +340,7 @@ export class FormaRuntime {
}
scene.activeCamera = this.camera;
const sky = new B.HemisphericLight("Sky", B.Vector3.Up(), scene);
sky.intensity = p.settings.ambient;
sky.intensity = p.settings.rendering?.defaultLights === false ? 0 : p.settings.ambient;
sky.groundColor = B.Color3.FromHexString("#8a8475");
const sun = new B.DirectionalLight(
"Sun",
@@ -339,7 +348,7 @@ export class FormaRuntime {
scene,
);
sun.position = new B.Vector3(-12, 22, -14);
sun.intensity = 2.5;
sun.intensity = p.settings.rendering?.defaultLights === false ? 0 : 2.5;
sun.diffuse = B.Color3.FromHexString("#fff4df");
this.shadow = new B.ShadowGenerator(1024, sun);
this.shadow.usePercentageCloserFiltering = true;
@@ -451,11 +460,13 @@ export class FormaRuntime {
root.computeWorldMatrix(true);
const c = n.components.material;
if (c && (n.components.mesh?.type !== "model" || c.override)) {
for (const mesh of root.getChildMeshes()) {
for (const mesh of root.getChildMeshes().filter(mesh => mesh.metadata?.entityId === n.id)) {
const mat = mesh.material;
if (mat instanceof B.PBRMaterial) {
mat.albedoColor = B.Color3.FromHexString(c.color || "#91a697");
mat.emissiveColor = mat.albedoColor.scale(c.emissive || 0);
mat.roughness = c.roughness ?? 0.8;
mat.metallic = c.metallic ?? 0;
mat.alpha = c.alpha ?? 1;
mat.unlit = c.unlit === true;
mat.transparencyMode =
@@ -475,6 +486,8 @@ export class FormaRuntime {
node.dispose();
}
this.nodes.delete(id);
this.containers.get(id)?.dispose();
this.containers.delete(id);
this.signatures.delete(id);
this.animations.get(id)?.forEach((a) => a.dispose());
this.animations.delete(id);
@@ -490,7 +503,9 @@ export class FormaRuntime {
);
this.scene.shadowsEnabled = p.settings.shadows;
const sky = this.scene.getLightByName("Sky");
if (sky) sky.intensity = p.settings.ambient;
if (sky) sky.intensity = p.settings.rendering?.defaultLights === false ? 0 : p.settings.ambient;
const sun = this.scene.getLightByName("Sun");
if (sun) sun.intensity = p.settings.rendering?.defaultLights === false ? 0 : 2.5;
this.engine.setHardwareScalingLevel(1 / p.settings.renderScale);
const live = new Set(this.state.map((n) => n.id));
for (const id of this.nodes.keys()) if (!live.has(id)) this.removeNode(id);
@@ -532,61 +547,40 @@ export class FormaRuntime {
if (m.type === "model") {
const a = p.assets.find((a) => a.id === m.assetId);
if (!a?.uri) throw Error("У модели нет файла");
const key = a.id + "|" + a.uri;
let container = this.containers.get(key);
if (!container) {
let bytes: Uint8Array;
if (this.options.readAsset)
bytes = await this.options.readAsset(a.uri);
else {
const r = await fetch(a.uri);
if (!r.ok) throw Error("Ошибка загрузки " + a.name);
bytes = new Uint8Array(await r.arrayBuffer());
}
if (scene.isDisposed || this.disposed) return;
inspectModel(bytes, a.name);
container = await B.LoadAssetContainerAsync(bytes, scene, {
pluginExtension: a.name.toLowerCase().endsWith(".gltf")
? ".gltf"
: ".glb",
name: a.name,
});
if (scene.isDisposed || this.disposed) {
container.dispose();
return;
}
this.containers.set(key, container);
const info = {
clips: container.animationGroups.map((g) => g.name),
skeletons: container.skeletons.length,
triangles: container.meshes.reduce(
(s, m) => s + m.getTotalIndices() / 3,
0,
),
};
this.importInfo.set(a.id, info);
this.log(
"info",
"Импорт " +
a.name +
": " +
info.triangles +
" треугольников, " +
info.clips.length +
" анимаций",
);
let bytes: Uint8Array;
if (this.options.readAsset) bytes = await this.options.readAsset(a.uri);
else {
const response = await fetch(a.uri);
if (!response.ok) throw Error("Ошибка загрузки " + a.name);
bytes = new Uint8Array(await response.arrayBuffer());
}
const instance = container.instantiateModelsToScene(
(name) => n.id + "_" + name,
true,
{ doNotInstantiate: true },
);
for (const node of instance.rootNodes) node.parent = root;
this.animations.set(n.id, instance.animationGroups);
instance.animationGroups.forEach((g, i) => {
g.name = container!.animationGroups[i].name;
g.stop();
if (scene.isDisposed || this.disposed) return;
const metadata = inspectModel(bytes, a.name);
// A container per entity keeps cameras, lights, morph targets, textures
// and material animation targets independent. Mesh-only cloning can
// retain animation targets pointing into the cached source container.
const container = await B.LoadAssetContainerAsync(bytes, scene, {
pluginExtension: a.name.toLowerCase().endsWith(".gltf") ? ".gltf" : ".glb",
name: a.name,
pluginOptions: { gltf: { animationStartMode: 0 } },
});
if (scene.isDisposed || this.disposed) { container.dispose(); return; }
this.containers.set(n.id, container);
// getNodes() also includes Bones, whose parent must remain a Bone.
const roots = [...container.meshes, ...container.transformNodes, ...container.cameras, ...container.lights].filter(node => !node.parent);
container.addAllToScene();
for (const imported of roots) imported.parent = root;
for (const camera of container.cameras) camera.detachControl();
this.animations.set(n.id, container.animationGroups);
container.animationGroups.forEach(group => group.stop());
this.importInfo.set(a.id, {
...metadata,
clips: container.animationGroups.map(group => group.name),
cameraNames: container.cameras.map(camera => camera.name),
triangles: container.meshes.reduce((sum, mesh) => sum + mesh.getTotalIndices() / 3, 0),
});
for (const warning of metadata.warnings) this.log("warning", warning, n.id);
this.log("info", `Импорт ${a.name}: ${metadata.nodes} узлов, ${metadata.clips.length} анимаций, ${metadata.animatedProperties.length} анимированных свойств`, n.id);
meshes = root.getChildMeshes();
} else if (m.type === "custom" || m.type === "geometry") {
const g =
@@ -712,7 +706,7 @@ export class FormaRuntime {
sign.isPickable = false;
}
for (const mesh of meshes) {
mesh.metadata = { entityId: n.id };
mesh.metadata = { ...mesh.metadata, entityId: n.id };
mesh.isPickable = true;
mesh.receiveShadows = n.components.mesh?.receiveShadows !== false;
if (n.components.mesh?.castShadows !== false)
@@ -829,6 +823,10 @@ export class FormaRuntime {
)?.components.camera;
this.look = { yaw: fp?.yaw ?? 0, pitch: fp?.pitch ?? 0 };
this.currentAnims.clear();
for (const node of this.state) {
const animator = node.components.animator;
if (node.enabled && animator?.autoplay) this.animate(node.id, animator.autoplay, animator.loop !== false);
}
if (!this.options.headless && p.settings.presentation) {
this.presentation?.dispose();
this.presentation = new RuntimePresentation(
@@ -1326,6 +1324,7 @@ export class FormaRuntime {
else this.flashes.set(id, t - dt);
}
try {
this.scene.animationsEnabled = !(this.playing && this.paused);
this.scene.render();
} catch (e) {
this.log("error", "Render: " + String(e));
@@ -1364,10 +1363,17 @@ export class FormaRuntime {
B.Vector3.FromArray(entity!.transform.position),
);
this.gameCamera.setTarget(B.Vector3.FromArray(c.lookAt || [0, 0, 0]));
this.gameCamera.fov = c.fov || 0.72;
}
private updateProjection() {
const c = this.state.find((n) => n.enabled && n.components.camera)
?.components.camera;
const cameraEntity = this.state.find(n => n.enabled && n.components.camera);
const c = cameraEntity?.components.camera;
if (cameraEntity && c?.mode === "imported") {
const cameras = this.containers.get(cameraEntity.id)?.cameras || [];
const imported = cameras.find(camera => camera.name === c.cameraName) || cameras[0];
if (imported) { this.scene.activeCamera = imported; return; }
}
this.scene.activeCamera = this.gameCamera;
const cam = this.gameCamera;
cam.mode =
c?.projection === "orthographic"