Fix editor regressions and add Blender scene and animated material import
This commit is contained in:
+5
-2
@@ -14,6 +14,9 @@ export function decodeData(uri: string) {
|
||||
throw Error("Ожидался data URI base64");
|
||||
return Uint8Array.from(atob(uri.slice(i + 1)), (c) => c.charCodeAt(0));
|
||||
}
|
||||
export function engineResource(path: string, base = typeof document === "undefined" ? undefined : document.baseURI) {
|
||||
return base ? new URL(path.replace(/^\//, ""), base).href : path;
|
||||
}
|
||||
export async function readBytes(uri: string) {
|
||||
if (uri.startsWith("data:")) return decodeData(uri);
|
||||
const r = await fetch(uri);
|
||||
@@ -57,8 +60,8 @@ export async function gameArchive(
|
||||
) {
|
||||
const { p, files } = await pack(project, read);
|
||||
files["project.forma.json"] = strToU8(JSON.stringify(p));
|
||||
files["player.js"] = await read("/engine/player.js");
|
||||
files["player.css"] = await read("/engine/player.css");
|
||||
files["player.js"] = await read(engineResource("/engine/player.js"));
|
||||
files["player.css"] = await read(engineResource("/engine/player.css"));
|
||||
files["index.html"] = strToU8(
|
||||
'<!doctype html><html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><title>' +
|
||||
p.name.replace(/[<>&"]/g, "") +
|
||||
|
||||
+3
-3
@@ -1,5 +1,5 @@
|
||||
import { zipSync, unzipSync, strToU8 } from "fflate";
|
||||
import { gameArchive, readBytes } from "./archive.ts";
|
||||
import { gameArchive, readBytes, engineResource } from "./archive.ts";
|
||||
import type { Project } from "./schema.ts";
|
||||
import { normalizeOptions } from "../native/options.mjs";
|
||||
export async function buildKit(
|
||||
@@ -13,7 +13,7 @@ export async function buildKit(
|
||||
for (const [name, data] of Object.entries(game))
|
||||
files["native/game/" + name] = data;
|
||||
const manifest = JSON.parse(
|
||||
new TextDecoder().decode(await read("/build-targets/manifest.json")),
|
||||
new TextDecoder().decode(await read(engineResource("/build-targets/manifest.json"))),
|
||||
) as string[];
|
||||
for (const name of manifest) {
|
||||
if (
|
||||
@@ -22,7 +22,7 @@ export async function buildKit(
|
||||
name.startsWith("/")
|
||||
)
|
||||
throw Error("Invalid build template path");
|
||||
files["native/" + name] = await read("/build-targets/" + name);
|
||||
files["native/" + name] = await read(engineResource("/build-targets/" + name));
|
||||
}
|
||||
files["native/build-config.json"] = strToU8(JSON.stringify(options, null, 2));
|
||||
files["README.txt"] = strToU8(
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import "@babylonjs/loaders/glTF";
|
||||
import { Animation } from "@babylonjs/core/Animations/animation.js";
|
||||
import { AnimationPropertyInfo } from "@babylonjs/loaders/glTF/2.0/glTFLoaderAnimation.js";
|
||||
import { SetInterpolationForKey } from "@babylonjs/loaders/glTF/2.0/Extensions/objectModelMapping.js";
|
||||
|
||||
// Babylon 9.25.0 registers metallicFactor twice and omits roughnessFactor.
|
||||
// Its metallic-roughness texture pointer paths also omit KHR_texture_transform.
|
||||
// Keep these corrections covered by actual glTF animation playback tests.
|
||||
class MaterialProperty extends AnimationPropertyInfo {
|
||||
buildAnimations(target: any, name: string, fps: number, keys: any[]) {
|
||||
return Object.values(target._data || {}).map((data: any) => ({
|
||||
babylonAnimatable: data.babylonMaterial,
|
||||
babylonAnimation: this._buildAnimation(name, fps, keys),
|
||||
}));
|
||||
}
|
||||
}
|
||||
const scalar = (property: string, index = 0, stride = 1) => new MaterialProperty(
|
||||
Animation.ANIMATIONTYPE_FLOAT, property,
|
||||
(_target, source, offset, scale) => source[offset + index] * scale,
|
||||
() => stride,
|
||||
);
|
||||
SetInterpolationForKey("/materials/{}/pbrMetallicRoughness/metallicFactor", [scalar("metallic")]);
|
||||
SetInterpolationForKey("/materials/{}/pbrMetallicRoughness/roughnessFactor", [scalar("roughness")]);
|
||||
const texturePath = "/materials/{}/pbrMetallicRoughness/metallicRoughnessTexture/extensions/KHR_texture_transform/";
|
||||
SetInterpolationForKey(texturePath + "offset", [scalar("metallicTexture.uOffset", 0, 2), scalar("metallicTexture.vOffset", 1, 2)]);
|
||||
SetInterpolationForKey(texturePath + "scale", [scalar("metallicTexture.uScale", 0, 2), scalar("metallicTexture.vScale", 1, 2)]);
|
||||
SetInterpolationForKey(texturePath + "rotation", [new MaterialProperty(Animation.ANIMATIONTYPE_FLOAT, "metallicTexture.wAng", (_t, s, o, scale) => -s[o] * scale, () => 1)]);
|
||||
|
||||
// xmag/ymag are scalar half-extents; both sides use the same sample.
|
||||
class CameraProperty extends AnimationPropertyInfo {
|
||||
buildAnimations(target: any, name: string, fps: number, keys: any[]) {
|
||||
return [{ babylonAnimatable: target._babylonCamera, babylonAnimation: this._buildAnimation(name, fps, keys) }];
|
||||
}
|
||||
}
|
||||
for (const [axis, negative, positive] of [["xmag", "orthoLeft", "orthoRight"], ["ymag", "orthoBottom", "orthoTop"]]) {
|
||||
SetInterpolationForKey(`/cameras/{}/orthographic/${axis}`, [
|
||||
new CameraProperty(Animation.ANIMATIONTYPE_FLOAT, negative, (_t, s, o, scale) => -s[o] * scale, () => 1),
|
||||
new CameraProperty(Animation.ANIMATIONTYPE_FLOAT, positive, (_t, s, o, scale) => s[o] * scale, () => 1),
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { unzipSync } from "fflate";
|
||||
import { base64, mimeFor } from "./archive.ts";
|
||||
import { inspectModel, modelDocument } from "./model.ts";
|
||||
import { emptyProject, entity, uid, activeScene } from "./schema.ts";
|
||||
|
||||
/** Resolve inside a supplied bundle. Never fetch network or arbitrary local files. */
|
||||
export function modelResourcePath(model: string, uri: string) {
|
||||
const decoded = decodeURIComponent(uri);
|
||||
if (/^[a-z][a-z\d+.-]*:/i.test(decoded) || /^[\\/]/.test(decoded) || /[\\?#\0]/.test(decoded))
|
||||
throw Error("Недопустимый путь ресурса: " + uri);
|
||||
const parts = model.split("/").slice(0, -1);
|
||||
for (const part of decoded.split("/")) {
|
||||
if (part === "..") { if (!parts.length) throw Error("Ресурс выходит за каталог импорта"); parts.pop(); }
|
||||
else if (part && part !== ".") parts.push(part);
|
||||
}
|
||||
return parts.join("/");
|
||||
}
|
||||
|
||||
export async function portableModel(
|
||||
bytes: Uint8Array,
|
||||
name: string,
|
||||
read?: (path: string) => Promise<Uint8Array>,
|
||||
): Promise<{ bytes: Uint8Array; name: string; metadata: ReturnType<typeof inspectModel> }> {
|
||||
if (/\.zip$/i.test(name)) {
|
||||
if (bytes.byteLength > 25 * 1024 * 1024) throw Error("Лимит ZIP модели: 25 МБ");
|
||||
let size = 0;
|
||||
const files = unzipSync(bytes, { filter: (file) => {
|
||||
size += file.originalSize;
|
||||
if (size > 80 * 1024 * 1024 || file.originalSize > 25 * 1024 * 1024)
|
||||
throw Error("Слишком большой архив модели");
|
||||
if (file.name.startsWith("/") || file.name.includes("\\") || file.name.split("/").includes(".."))
|
||||
throw Error("Небезопасный путь в ZIP");
|
||||
return true;
|
||||
} });
|
||||
const models = Object.keys(files).filter(path => /\.(glb|gltf)$/i.test(path) && !path.startsWith("__MACOSX/"));
|
||||
if (models.length !== 1) throw Error("ZIP должен содержать ровно одну GLB/glTF-сцену и её ресурсы");
|
||||
const model = models[0];
|
||||
return portableModel(files[model], model, async path => {
|
||||
if (!files[path]) throw Error("Отсутствует ресурс: " + path);
|
||||
return files[path];
|
||||
});
|
||||
}
|
||||
if (!/\.(glb|gltf)$/i.test(name)) throw Error("Ожидался GLB, glTF или ZIP модели");
|
||||
if (/\.gltf$/i.test(name)) {
|
||||
const doc = modelDocument(bytes, name);
|
||||
let total = bytes.byteLength;
|
||||
for (const [kind, list] of [["buffer", doc.buffers || []], ["image", doc.images || []]] as const) {
|
||||
for (const item of list) {
|
||||
if (!item.uri || item.uri.startsWith("data:")) continue;
|
||||
const path = modelResourcePath(name, item.uri);
|
||||
if (!read) throw Error("Добавь связанные файлы или импортируй ZIP: " + path);
|
||||
const resource = await read(path);
|
||||
total += resource.byteLength;
|
||||
if (total > 25 * 1024 * 1024) throw Error("Лимит ресурсов модели: 25 МБ");
|
||||
const ext = path.split(".").pop()?.toLowerCase();
|
||||
const mime = kind === "buffer" ? "application/octet-stream" : ({ png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", webp: "image/webp" } as Record<string, string>)[ext || ""];
|
||||
if (!mime) throw Error("Неподдерживаемая текстура: " + path);
|
||||
item.uri = `data:${mime};base64,${base64(resource)}`;
|
||||
}
|
||||
}
|
||||
bytes = new TextEncoder().encode(JSON.stringify(doc));
|
||||
}
|
||||
name = name.split("/").pop()!;
|
||||
return { bytes, name, metadata: inspectModel(bytes, name) };
|
||||
}
|
||||
|
||||
export function modelComponents(assetId: string, metadata: ReturnType<typeof inspectModel>) {
|
||||
return {
|
||||
mesh: { type: "model", assetId },
|
||||
...(metadata.clips.length ? { animator: { autoplay: metadata.clips[0], loop: true, speed: 1 } } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export const modelDataUri = (model: { name: string; bytes: Uint8Array }) =>
|
||||
`data:${mimeFor(model.name)};base64,${base64(model.bytes)}`;
|
||||
|
||||
/** Open a complete Blender scene as an undoable project replacement. */
|
||||
export function modelProject(model: Awaited<ReturnType<typeof portableModel>>) {
|
||||
const project = emptyProject(model.name.replace(/\.(glb|gltf)$/i, ""));
|
||||
const id = uid("asset");
|
||||
project.assets = [{ id, name: model.name, kind: "model", uri: modelDataUri(model), metadata: model.metadata }];
|
||||
const components = { ...modelComponents(id, model.metadata), ...(model.metadata.cameras.length ? { camera: { mode: "imported", ...(model.metadata.activeCamera ? { cameraName: model.metadata.activeCamera } : {}) } } : {}) };
|
||||
activeScene(project).entities = [entity(project.name, components)];
|
||||
if (model.metadata.lights) {
|
||||
project.settings.ambient = 0;
|
||||
project.settings.rendering = { defaultLights: false };
|
||||
}
|
||||
return project;
|
||||
}
|
||||
+17
-2
@@ -1,5 +1,5 @@
|
||||
/** Validate portable model containers before allowing renderer-side resource loads. */
|
||||
export function inspectModel(bytes: Uint8Array, name: string) {
|
||||
export function modelDocument(bytes: Uint8Array, name: string) {
|
||||
if (bytes.byteLength < 12 || bytes.byteLength > 25 * 1024 * 1024)
|
||||
throw Error("Размер модели: 12 байт — 25 МБ");
|
||||
let gltf: any;
|
||||
@@ -24,6 +24,11 @@ export function inspectModel(bytes: Uint8Array, name: string) {
|
||||
);
|
||||
}
|
||||
if (gltf.asset?.version !== "2.0") throw Error("Поддерживается glTF 2.0");
|
||||
return gltf;
|
||||
}
|
||||
export function inspectModel(bytes: Uint8Array, name: string) {
|
||||
const gltf = modelDocument(bytes, name);
|
||||
if (gltf.asset?.version !== "2.0") throw Error("Поддерживается glTF 2.0");
|
||||
if (
|
||||
[...(gltf.buffers || []), ...(gltf.images || [])].some(
|
||||
(r: any) => r.uri && !r.uri.startsWith("data:"),
|
||||
@@ -41,12 +46,22 @@ export function inspectModel(bytes: Uint8Array, name: string) {
|
||||
)
|
||||
)
|
||||
throw Error(
|
||||
"Для автономного экспорта 0.1 используй GLB без Draco, Meshopt и KTX2",
|
||||
"Используй экспорт без Draco, Meshopt и KTX2: автономные декодеры не включены",
|
||||
);
|
||||
return {
|
||||
clips: (gltf.animations || []).map(
|
||||
(a: any, i: number) => a.name || "Animation " + i,
|
||||
),
|
||||
skeletons: (gltf.skins || []).length,
|
||||
nodes: (gltf.nodes || []).length,
|
||||
meshes: (gltf.meshes || []).length,
|
||||
materials: (gltf.materials || []).map((m: any, i: number) => m.name || `Material ${i}`),
|
||||
cameras: (gltf.cameras || []).map((c: any, i: number) => c.name || `Camera ${i}`),
|
||||
activeCamera: typeof gltf.extras?.forma?.activeCamera === "string" ? gltf.extras.forma.activeCamera : null,
|
||||
lights: (gltf.extensions?.KHR_lights_punctual?.lights || []).length + (gltf.extensions?.EXT_lights_area?.lights || []).length,
|
||||
morphTargets: (gltf.meshes || []).reduce((sum: number, m: any) => sum + Math.max(0, ...(m.primitives || []).map((p: any) => p.targets?.length || 0)), 0),
|
||||
animatedProperties: [...new Set<string>((gltf.animations || []).flatMap((a: any) => (a.channels || []).map((c: any) => c.target?.extensions?.KHR_animation_pointer?.pointer).filter(Boolean)))],
|
||||
extensions: gltf.extensionsUsed || [],
|
||||
warnings: (Array.isArray(gltf.extras?.forma?.warnings) ? gltf.extras.forma.warnings : []).filter((w: any) => typeof w === "string").slice(0, 100),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -74,7 +74,6 @@ async function boot() {
|
||||
const stick = document.getElementById("stick")!;
|
||||
const knob = stick.querySelector("i")!;
|
||||
let stickId: number | null = null;
|
||||
let look: { id: number; x: number; y: number } | null = null;
|
||||
const move = (event: PointerEvent) => {
|
||||
if (event.pointerId !== stickId || runtime.paused) return;
|
||||
const bounds = stick.getBoundingClientRect();
|
||||
@@ -111,37 +110,11 @@ async function boot() {
|
||||
runtime.touch[input] = false;
|
||||
};
|
||||
}
|
||||
canvas.addEventListener("pointerdown", (event) => {
|
||||
if (!firstPerson || runtime.paused) return;
|
||||
if (event.pointerType === "mouse") {
|
||||
const lock = canvas.requestPointerLock?.();
|
||||
if (lock && typeof lock.catch === "function") lock.catch(() => {});
|
||||
} else {
|
||||
look = { id: event.pointerId, x: event.clientX, y: event.clientY };
|
||||
canvas.setPointerCapture(event.pointerId);
|
||||
}
|
||||
});
|
||||
canvas.addEventListener("pointermove", (event) => {
|
||||
if (!look || look.id !== event.pointerId || runtime.paused) return;
|
||||
runtime.lookBy(
|
||||
(event.clientX - look.x) * 0.004,
|
||||
(look.y - event.clientY) * 0.004,
|
||||
);
|
||||
look.x = event.clientX;
|
||||
look.y = event.clientY;
|
||||
});
|
||||
const release = () => {
|
||||
stickId = null;
|
||||
look = null;
|
||||
runtime.releaseInput();
|
||||
knob.style.transform = "";
|
||||
};
|
||||
canvas.addEventListener("pointerup", () => {
|
||||
look = null;
|
||||
});
|
||||
canvas.addEventListener("pointercancel", () => {
|
||||
look = null;
|
||||
});
|
||||
window.addEventListener("blur", release);
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
release();
|
||||
|
||||
+67
-61
@@ -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"
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@ export interface Project {
|
||||
ambient: number;
|
||||
shadows: boolean;
|
||||
renderScale: number;
|
||||
rendering?: { toneMapping?: boolean; exposure?: number; contrast?: number };
|
||||
rendering?: { toneMapping?: boolean; exposure?: number; contrast?: number; defaultLights?: boolean };
|
||||
controls?: Partial<
|
||||
Record<"attack" | "jump" | "dash" | "sprint" | "reset", string[]>
|
||||
>;
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/** Shared by the editor and both standalone player presentations. */
|
||||
export function bindViewInput(
|
||||
canvas: HTMLCanvasElement,
|
||||
runtime: { playing: boolean; paused: boolean; firstPerson: () => boolean; lookBy: (x: number, y: number) => void },
|
||||
) {
|
||||
let pointer: { id: number; x: number; y: number } | null = null;
|
||||
const down = (event: PointerEvent) => {
|
||||
if (!runtime.playing || runtime.paused || !runtime.firstPerson()) return;
|
||||
if (event.pointerType === "mouse") {
|
||||
if (event.button !== 0) return;
|
||||
try { void canvas.requestPointerLock?.()?.catch(() => {}); } catch {}
|
||||
} else {
|
||||
pointer = { id: event.pointerId, x: event.clientX, y: event.clientY };
|
||||
canvas.setPointerCapture(event.pointerId);
|
||||
}
|
||||
};
|
||||
const move = (event: PointerEvent) => {
|
||||
if (!pointer || event.pointerId !== pointer.id) return;
|
||||
if (!runtime.playing || runtime.paused || !runtime.firstPerson()) { pointer = null; return; }
|
||||
runtime.lookBy((event.clientX - pointer.x) * .004, (pointer.y - event.clientY) * .004);
|
||||
pointer = { id: event.pointerId, x: event.clientX, y: event.clientY };
|
||||
};
|
||||
const release = () => { pointer = null; };
|
||||
canvas.addEventListener("pointerdown", down);
|
||||
canvas.addEventListener("pointermove", move);
|
||||
canvas.addEventListener("pointerup", release);
|
||||
canvas.addEventListener("pointercancel", release);
|
||||
canvas.addEventListener("lostpointercapture", release);
|
||||
canvas.addEventListener("blur", release);
|
||||
return () => {
|
||||
canvas.removeEventListener("pointerdown", down);
|
||||
canvas.removeEventListener("pointermove", move);
|
||||
canvas.removeEventListener("pointerup", release);
|
||||
canvas.removeEventListener("pointercancel", release);
|
||||
canvas.removeEventListener("lostpointercapture", release);
|
||||
canvas.removeEventListener("blur", release);
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user