web打包更新

This commit is contained in:
Rowland 2026-03-24 08:54:28 +08:00
parent 681d13ea48
commit d412c761b2
3 changed files with 1566 additions and 9 deletions

View File

@ -84,12 +84,12 @@ Size=400,300
Collapsed=0
[Window][选择路径]
Pos=390,125
Pos=625,258
Size=600,500
Collapsed=0
[Window][打开项目]
Pos=440,175
Pos=675,308
Size=500,400
Collapsed=0

View File

@ -142,6 +142,8 @@ class WebGLPackager:
shutil.copy2(str(entry), str(vendor_dst / entry.name))
self._try_resolve_vendor_files(vendor_dst)
self._copy_optional_postprocess_vendor_files(vendor_dst)
self._rewrite_vendor_module_imports(vendor_dst)
self._strip_vendor_source_mapping_urls(vendor_dst)
# Placeholder marker warning
@ -204,6 +206,118 @@ class WebGLPackager:
if found_source:
shutil.copy2(str(found_source), str(dst_path))
def _copy_optional_postprocess_vendor_files(self, vendor_dst: Path) -> None:
"""Try to copy optional postprocessing modules used by viewer approximation."""
lookup_roots = [
Path("/usr/share/javascript/three"),
Path("/usr/share/nodejs/three"),
Path("/usr/lib/node_modules/three"),
Path.home() / ".local/lib/node_modules/three",
]
optional_targets = {
"EffectComposer.js": [
"examples/jsm/postprocessing/EffectComposer.js",
],
"RenderPass.js": [
"examples/jsm/postprocessing/RenderPass.js",
],
"UnrealBloomPass.js": [
"examples/jsm/postprocessing/UnrealBloomPass.js",
],
"ShaderPass.js": [
"examples/jsm/postprocessing/ShaderPass.js",
],
"OutputPass.js": [
"examples/jsm/postprocessing/OutputPass.js",
],
"CopyShader.js": [
"examples/jsm/shaders/CopyShader.js",
],
"LuminosityHighPassShader.js": [
"examples/jsm/shaders/LuminosityHighPassShader.js",
],
}
for dst_name, rel_candidates in optional_targets.items():
dst_path = vendor_dst / dst_name
if dst_path.exists():
continue
found_source = None
for root in lookup_roots:
if not root.exists():
continue
for rel in rel_candidates:
candidate = root / rel
if candidate.exists() and candidate.is_file():
found_source = candidate
break
if found_source:
break
if found_source:
try:
shutil.copy2(str(found_source), str(dst_path))
except Exception:
continue
def _rewrite_vendor_module_imports(self, vendor_dst: Path) -> None:
"""Rewrite bare/relative Three.js addon imports to local vendor paths."""
js_files = [p for p in vendor_dst.glob("*.js") if p.is_file()]
if not js_files:
return
available_names = {p.name for p in js_files}
import_re = re.compile(
r"""(?P<prefix>\bfrom\s+)(?P<quote>['"])(?P<spec>[^'"]+)(?P=quote)"""
)
def rewrite_spec(spec: str) -> str:
raw = str(spec or "").strip()
if not raw:
return raw
if raw in {"three", "three.module.js", "three.module.min.js"}:
return "./three.module.min.js"
if raw.startswith("three/addons/") or raw.startswith("three/examples/jsm/"):
return "./" + raw.split("/")[-1]
if raw.endswith(".js"):
basename = os.path.basename(raw)
if basename in available_names:
return "./" + basename
return raw
for js_file in js_files:
if js_file.name == "three.module.min.js":
continue
try:
content = js_file.read_text(encoding="utf-8", errors="ignore")
except Exception:
continue
replaced = False
def _replace(match):
nonlocal replaced
old_spec = match.group("spec")
new_spec = rewrite_spec(old_spec)
if new_spec == old_spec:
return match.group(0)
replaced = True
return f"{match.group('prefix')}{match.group('quote')}{new_spec}{match.group('quote')}"
updated = import_re.sub(_replace, content)
if not replaced:
continue
try:
js_file.write_text(updated, encoding="utf-8")
except Exception:
continue
def _strip_vendor_source_mapping_urls(self, vendor_dst: Path) -> None:
"""Remove sourceMappingURL hints to avoid noisy 404s in offline preview."""
line_patterns = (
@ -449,7 +563,7 @@ class WebGLPackager:
except Exception:
pass
return {
entry = {
"include_default_ground": True,
"ambient_light": {
"color": ambient_color,
@ -461,6 +575,428 @@ class WebGLPackager:
"direction": directional_dir,
},
}
skybox_entry = self._extract_skybox_entry()
if skybox_entry:
entry["skybox"] = skybox_entry
render_pipeline = self._extract_render_pipeline_settings()
if render_pipeline:
entry["render_pipeline"] = render_pipeline
return entry
def _extract_skybox_entry(self) -> Dict[str, Any]:
"""Export skybox texture config for viewer background."""
skybox_source = self._find_runtime_skybox_texture_source()
if not skybox_source:
skybox_source = self._find_fallback_skybox_source()
if not skybox_source:
return {}
if not self._is_supported_skybox_image(skybox_source):
self.report["warnings"].append(f"天空盒格式暂不支持,已跳过: {skybox_source}")
return {}
uri = self._copy_asset_to_textures(skybox_source)
if not uri:
self.report["warnings"].append(f"天空盒资源复制失败: {skybox_source}")
return {}
projection = self._guess_skybox_projection(skybox_source)
lower_color = self._extract_skybox_lower_hemisphere_color(skybox_source)
return {
"enabled": True,
"type": projection,
"uri": uri,
"apply_environment": projection != "skydome",
"clip_lower_hemisphere": projection == "skydome",
"lower_hemisphere_color": lower_color,
"horizon_blend": 0.06,
"horizon_sample_v": 0.01,
"lower_tint_strength": 0.65,
}
def _extract_skybox_lower_hemisphere_color(self, skybox_source: str) -> List[float]:
"""Pick lower hemisphere fill color, prefer horizon-adjacent sky tone."""
horizon_rgb = self._estimate_skybox_horizon_rgb(skybox_source)
if horizon_rgb:
return horizon_rgb
clear_rgb = self._extract_world_clear_rgb()
if clear_rgb and max(clear_rgb) > 0.02:
return clear_rgb
# Final fallback: dark bluish neutral.
return [0.08, 0.10, 0.13]
def _extract_world_clear_rgb(self) -> List[float]:
"""Read active clear/background color from world/showbase if available."""
def _to_rgb(value: Any) -> List[float]:
if value is None:
return []
try:
if hasattr(value, "__len__") and len(value) >= 3: # type: ignore[arg-type]
r = float(value[0]) # type: ignore[index]
g = float(value[1]) # type: ignore[index]
b = float(value[2]) # type: ignore[index]
return [max(0.0, min(1.0, r)), max(0.0, min(1.0, g)), max(0.0, min(1.0, b))]
except Exception:
return []
return []
world_getters = ("getBackgroundColor", "get_background_color")
for name in world_getters:
getter = getattr(self.world, name, None)
if callable(getter):
try:
rgb = _to_rgb(getter())
except Exception:
rgb = []
if rgb:
return rgb
win = getattr(self.world, "win", None)
if win and hasattr(win, "getClearColor"):
try:
rgb = _to_rgb(win.getClearColor())
except Exception:
rgb = []
if rgb:
return rgb
rp = getattr(self.world, "render_pipeline", None)
showbase = getattr(rp, "_showbase", None) if rp else None
rp_win = getattr(showbase, "win", None) if showbase else None
if rp_win and hasattr(rp_win, "getClearColor"):
try:
rgb = _to_rgb(rp_win.getClearColor())
except Exception:
rgb = []
if rgb:
return rgb
return []
@staticmethod
def _estimate_skybox_horizon_rgb(path: str) -> List[float]:
"""Estimate horizon color from skydome texture bottom band."""
try:
from PIL import Image, ImageStat # type: ignore
except Exception:
return []
try:
with Image.open(path) as im:
rgb_img = im.convert("RGB")
width, height = rgb_img.size
if width <= 0 or height <= 0:
return []
band_h = max(1, int(height // 64))
band = rgb_img.crop((0, height - band_h, width, height))
stat = ImageStat.Stat(band)
mean = stat.mean[:3] if stat.mean else [0.0, 0.0, 0.0]
return [
max(0.0, min(1.0, float(mean[0]) / 255.0)),
max(0.0, min(1.0, float(mean[1]) / 255.0)),
max(0.0, min(1.0, float(mean[2]) / 255.0)),
]
except Exception:
return []
def _find_runtime_skybox_texture_source(self) -> str:
candidates: List[str] = []
skybox_np = getattr(self.world, "skybox", None)
if skybox_np and not self._is_np_empty(skybox_np):
candidates.extend(self._extract_texture_sources_from_node(skybox_np))
render = getattr(self.world, "render", None)
if render:
try:
skyboxes = render.findAllMatches("**/skybox*")
except Exception:
skyboxes = None
if skyboxes:
try:
count = skyboxes.getNumPaths()
except Exception:
count = 0
for i in range(count):
try:
np = skyboxes.getPath(i)
except Exception:
continue
if self._is_np_empty(np):
continue
candidates.extend(self._extract_texture_sources_from_node(np))
for source in candidates:
if source and self._is_supported_skybox_image(source):
return source
return ""
def _extract_texture_sources_from_node(self, node) -> List[str]:
out: List[str] = []
for _, tex_hint in self._extract_texture_stage_and_paths(node):
resolved = self._resolve_skybox_path(tex_hint)
if resolved:
out.append(resolved)
return out
def _resolve_skybox_path(self, path_hint: str) -> str:
text = str(path_hint or "").strip()
if not text:
return ""
if os.path.isabs(text) and os.path.exists(text):
return os.path.normpath(text)
repo_root = str(Path(__file__).resolve().parent.parent)
roots = [
self._project_path,
os.path.join(self._project_path, "Resources"),
os.path.join(self._project_path, "scenes", "resources"),
repo_root,
os.path.join(repo_root, "RenderPipelineFile"),
os.path.join(repo_root, "RenderPipelineFile", "data"),
os.path.join(repo_root, "RenderPipelineFile", "data", "builtin_models", "skybox"),
os.getcwd(),
]
for root in roots:
if not root:
continue
full = os.path.normpath(os.path.join(root, text))
if os.path.exists(full):
return full
return ""
def _find_fallback_skybox_source(self) -> str:
repo_root = str(Path(__file__).resolve().parent.parent)
candidates = [
os.path.join(self._project_path, "Resources", "skybox.jpg"),
os.path.join(self._project_path, "Resources", "skybox.png"),
os.path.join(self._project_path, "skybox.jpg"),
os.path.join(self._project_path, "skybox.png"),
os.path.join(repo_root, "RenderPipelineFile", "data", "builtin_models", "skybox", "skybox.jpg"),
os.path.join(repo_root, "RenderPipelineFile", "data", "builtin_models", "skybox", "skybox-2.jpg"),
]
for path in candidates:
if path and os.path.exists(path) and self._is_supported_skybox_image(path):
return os.path.normpath(path)
return ""
@staticmethod
def _is_supported_skybox_image(path: str) -> bool:
ext = os.path.splitext(str(path or ""))[1].lower()
return ext in {".jpg", ".jpeg", ".png", ".webp", ".avif"}
@staticmethod
def _guess_skybox_projection(path: str) -> str:
text = str(path or "").replace("\\", "/").lower()
if "/builtin_models/skybox/skybox" in text:
# RenderPipeline default assets are skydome textures.
return "skydome"
width = 0
height = 0
try:
from PIL import Image # type: ignore
with Image.open(path) as im:
width, height = im.size
except Exception:
width, height = 0, 0
if width > 0 and height > 0:
ratio = float(width) / float(max(1, height))
if 3.2 <= ratio <= 4.8:
return "skydome"
if 1.8 <= ratio <= 2.2:
return "equirectangular"
return "equirectangular"
def _extract_render_pipeline_settings(self) -> Dict[str, Any]:
rp = getattr(self.world, "render_pipeline", None)
plugin_mgr = getattr(rp, "plugin_mgr", None) if rp else None
if not plugin_mgr:
return {}
enabled_plugins = set(getattr(plugin_mgr, "enabled_plugins", set()) or set())
tone_operator = str(
self._get_rp_plugin_setting(plugin_mgr, "color_correction", "tonemap_operator", "optimized")
).strip().lower()
if not tone_operator:
tone_operator = "optimized"
exposure_scale = self._safe_float(
self._get_rp_plugin_setting(plugin_mgr, "color_correction", "exposure_scale", 1.0),
1.0,
)
min_exposure = self._safe_float(
self._get_rp_plugin_setting(plugin_mgr, "color_correction", "min_exposure_value", 0.01),
0.01,
)
max_exposure = self._safe_float(
self._get_rp_plugin_setting(plugin_mgr, "color_correction", "max_exposure_value", 1.0),
1.0,
)
if max_exposure < min_exposure:
min_exposure, max_exposure = max_exposure, min_exposure
manual_camera = bool(
self._get_rp_plugin_setting(plugin_mgr, "color_correction", "manual_camera_parameters", False)
)
web_exposure_boost = 1.0
if tone_operator in {"optimized", "uncharted2"}:
web_exposure_boost = 1.6
elif tone_operator == "reinhard":
web_exposure_boost = 1.25
elif tone_operator in {"exponential", "exponential2"}:
web_exposure_boost = 1.15
bloom_enabled = "bloom" in enabled_plugins
bloom_strength = self._safe_float(
self._get_rp_plugin_setting(plugin_mgr, "bloom", "bloom_strength", 0.0),
0.0,
)
bloom_mips = int(self._safe_float(
self._get_rp_plugin_setting(plugin_mgr, "bloom", "num_mipmaps", 6),
6.0,
))
bloom_lens_dirt = self._safe_float(
self._get_rp_plugin_setting(plugin_mgr, "bloom", "lens_dirt_factor", 0.0),
0.0,
)
pssm_enabled = "pssm" in enabled_plugins
shadow_resolution = int(self._safe_float(
self._get_rp_plugin_setting(plugin_mgr, "pssm", "resolution", 1024),
1024.0,
))
shadow_resolution = max(256, min(4096, shadow_resolution))
shadow_max_distance = self._safe_float(
self._get_rp_plugin_setting(plugin_mgr, "pssm", "max_distance", 50.0),
50.0,
)
shadow_use_pcf = bool(
self._get_rp_plugin_setting(plugin_mgr, "pssm", "use_pcf", True)
)
ao_enabled = "ao" in enabled_plugins
ao_strength = self._safe_float(
self._get_rp_plugin_setting(plugin_mgr, "ao", "occlusion_strength", 1.0),
1.0,
)
ao_technique = str(
self._get_rp_plugin_setting(plugin_mgr, "ao", "technique", "SSAO")
).strip().upper() or "SSAO"
daytime_value = self._safe_float(getattr(getattr(rp, "daytime_mgr", None), "time", 12.0), 12.0)
fog_enabled = "volumetrics" in enabled_plugins
fog_ramp = self._safe_float(
self._get_rp_day_setting(plugin_mgr, rp, "volumetrics", "fog_ramp_size", 2000.0),
2000.0,
)
fog_intensity = self._safe_float(
self._get_rp_day_setting(plugin_mgr, rp, "volumetrics", "fog_intensity", 0.0),
0.0,
)
fog_color_raw = self._get_rp_day_setting(plugin_mgr, rp, "volumetrics", "fog_color", (140.0, 150.0, 165.0))
fog_color = self._normalize_daytime_color(fog_color_raw)
# Viewer side optional postprocessing modules.
has_bloom_vendor = all(
os.path.exists(os.path.join(self._output_root, "vendor", name))
for name in ("EffectComposer.js", "RenderPass.js", "UnrealBloomPass.js")
)
if bloom_enabled and not has_bloom_vendor:
self.report["warnings"].append(
"Bloom 已启用,但未找到 EffectComposer/RenderPass/UnrealBloomPass本次 Web 导出将退化为无 Bloom。"
)
return {
"source": "render_pipeline",
"daytime": daytime_value,
"enabled_plugins": sorted(str(v) for v in enabled_plugins),
"tone_mapping": {
"operator": tone_operator,
"exposure_scale": exposure_scale,
"web_exposure_boost": web_exposure_boost,
"web_use_tone_mapping": False,
"manual_camera_parameters": manual_camera,
"min_exposure": min_exposure,
"max_exposure": max_exposure,
},
"bloom": {
"enabled": bloom_enabled and bloom_strength > 1e-5,
"strength": max(0.0, bloom_strength),
"mipmaps": max(2, bloom_mips),
"lens_dirt_factor": max(0.0, min(1.0, bloom_lens_dirt)),
"vendor_available": has_bloom_vendor,
},
"shadows": {
"enabled": pssm_enabled,
"resolution": shadow_resolution,
"max_distance": max(1.0, shadow_max_distance),
"use_pcf": shadow_use_pcf,
"web_enable": False,
},
"ao": {
"enabled": ao_enabled,
"technique": ao_technique,
"occlusion_strength": max(0.0, ao_strength),
},
"fog": {
"enabled": fog_enabled and fog_intensity > 1e-6,
"color": fog_color,
"intensity": max(0.0, fog_intensity),
"ramp_size": max(1.0, fog_ramp),
},
"aa": {
"smaa_enabled": "smaa" in enabled_plugins,
"fxaa_enabled": "fxaa" in enabled_plugins,
},
}
@staticmethod
def _get_rp_plugin_setting(plugin_mgr, plugin_id: str, setting_id: str, default: Any) -> Any:
try:
plugin_settings = getattr(plugin_mgr, "settings", {}).get(plugin_id)
if plugin_settings and setting_id in plugin_settings:
handle = plugin_settings[setting_id]
return getattr(handle, "value", default)
except Exception:
pass
return default
def _get_rp_day_setting(self, plugin_mgr, rp, plugin_id: str, setting_id: str, default: Any) -> Any:
try:
day_settings = getattr(plugin_mgr, "day_settings", {}).get(plugin_id)
if not day_settings or setting_id not in day_settings:
return default
handle = day_settings[setting_id]
if not hasattr(handle, "get_scaled_value_at"):
return default
daytime = self._safe_float(getattr(getattr(rp, "daytime_mgr", None), "time", 12.0), 12.0)
return handle.get_scaled_value_at(daytime)
except Exception:
return default
@staticmethod
def _normalize_daytime_color(value: Any) -> List[float]:
if isinstance(value, (list, tuple)) and len(value) >= 3:
out = [
float(value[0]),
float(value[1]),
float(value[2]),
]
else:
out = [0.55, 0.6, 0.65]
if any(v > 1.0 for v in out):
out = [v / 255.0 for v in out]
return [
max(0.0, min(1.0, out[0])),
max(0.0, min(1.0, out[1])),
max(0.0, min(1.0, out[2])),
]
def _build_model_node_entry(self, node) -> Optional[Dict[str, Any]]:
node_id = self._node_id_by_pointer.get(id(node))
@ -512,6 +1048,9 @@ class WebGLPackager:
animation = self._extract_animation_settings(node, model_source)
if animation:
entry["animation"] = animation
scripts = self._extract_script_settings(node)
if scripts:
entry["scripts"] = scripts
if textures:
entry["texture_overrides"] = textures
if subnode_overrides:
@ -567,6 +1106,276 @@ class WebGLPackager:
"autoplay": mode != "stop",
}
def _extract_script_settings(self, node) -> List[Dict[str, Any]]:
runtime_entries = self._extract_runtime_script_entries(node)
tag_entries = self._extract_tag_script_entries(node)
if not runtime_entries and not tag_entries:
return []
merged_entries: List[Dict[str, Any]] = []
merged_index: Dict[str, int] = {}
for entry in runtime_entries:
name = str(entry.get("name", "")).strip()
key = self._normalize_script_name_key(name)
if not key:
continue
merged_index[key] = len(merged_entries)
merged_entries.append(dict(entry))
for entry in tag_entries:
name = str(entry.get("name", "")).strip()
key = self._normalize_script_name_key(name)
if not key:
continue
if key in merged_index:
runtime_entry = merged_entries[merged_index[key]]
if entry.get("file") and not runtime_entry.get("file"):
runtime_entry["file"] = entry.get("file")
continue
merged_index[key] = len(merged_entries)
merged_entries.append(dict(entry))
return merged_entries
def _extract_runtime_script_entries(self, node) -> List[Dict[str, Any]]:
script_manager = getattr(self.world, "script_manager", None)
if not script_manager:
return []
get_scripts = getattr(script_manager, "get_scripts_on_object", None)
if not callable(get_scripts):
return []
try:
script_components = get_scripts(node) or []
except Exception:
script_components = []
if not script_components:
return []
out: List[Dict[str, Any]] = []
loader = getattr(script_manager, "loader", None)
find_script_file = getattr(loader, "find_script_file", None) if loader else None
for component in script_components:
script_instance = getattr(component, "script_instance", None)
script_name = str(getattr(component, "script_name", "")).strip()
if not script_name and script_instance is not None:
script_name = script_instance.__class__.__name__
script_name = str(script_name or "").strip()
if not script_name:
continue
script_file = ""
if callable(find_script_file):
try:
script_file = str(find_script_file(script_name) or "").strip()
except Exception:
script_file = ""
entry: Dict[str, Any] = {
"name": script_name,
"enabled": bool(getattr(component, "enabled", True)),
}
if script_file:
entry["file"] = script_file
params = self._serialize_script_instance_params(script_instance)
if params:
entry["params"] = params
out.append(entry)
return out
def _extract_tag_script_entries(self, node) -> List[Dict[str, Any]]:
has_scripts = self._safe_get_tag_value(node, "has_scripts").lower() == "true"
raw = self._safe_get_tag_value(node, "scripts_info")
if not has_scripts and not raw:
return []
if not raw:
return []
try:
info_list = json.loads(raw)
except Exception:
self.report["warnings"].append(
f"scripts_info 解析失败,已忽略: {node.getName()}"
)
return []
if not isinstance(info_list, list):
return []
out: List[Dict[str, Any]] = []
for item in info_list:
if not isinstance(item, dict):
continue
script_name = str(item.get("name", "")).strip()
if not script_name:
continue
entry: Dict[str, Any] = {
"name": script_name,
"enabled": bool(item.get("enabled", True)),
}
script_file = str(item.get("file", "")).strip()
if script_file:
entry["file"] = script_file
params = item.get("params")
if isinstance(params, dict) and params:
entry["params"] = params
out.append(entry)
return out
@staticmethod
def _normalize_script_name_key(script_name: str) -> str:
normalized = str(script_name or "").strip().lower()
if normalized.endswith(".py"):
normalized = normalized[:-3]
normalized = re.sub(r"[^a-z0-9]+", "", normalized)
return normalized
def _serialize_script_instance_params(self, script_instance) -> Dict[str, Any]:
if not script_instance:
return {}
blocked_keys = {
"enabled",
"gameObject",
"transform",
"world",
}
out: Dict[str, Any] = {}
instance_vars = getattr(script_instance, "__dict__", {}) or {}
for key, value in instance_vars.items():
key_str = str(key).strip()
if not key_str:
continue
if key_str.startswith("_") or key_str in blocked_keys:
continue
serialized = self._serialize_script_value(value, depth=0)
if serialized is None and value is not None:
continue
out[key_str] = serialized
return out
def _serialize_script_value(self, value: Any, depth: int = 0) -> Any:
if depth > 4:
return None
if value is None:
return None
if isinstance(value, (bool, int, float, str)):
return value
if self._is_nodepath_like(value):
ref = self._serialize_node_reference(value)
return {"__node_ref__": ref} if ref else None
vec_value = self._serialize_vec_like(value)
if vec_value is not None:
return vec_value
if isinstance(value, (list, tuple)):
out = []
for item in value:
serialized = self._serialize_script_value(item, depth + 1)
if serialized is None and item is not None:
continue
out.append(serialized)
return out
if isinstance(value, dict):
out = {}
for k, v in value.items():
key = str(k)
if not key:
continue
serialized = self._serialize_script_value(v, depth + 1)
if serialized is None and v is not None:
continue
out[key] = serialized
return out
return None
@staticmethod
def _is_nodepath_like(value: Any) -> bool:
return (
value is not None
and hasattr(value, "getName")
and hasattr(value, "getParent")
and hasattr(value, "isEmpty")
)
def _serialize_node_reference(self, node) -> Dict[str, Any]:
if self._is_np_empty(node):
return {}
ref: Dict[str, Any] = {}
node_id = self._node_id_by_pointer.get(id(node))
if node_id:
ref["node_id"] = node_id
try:
node_name = str(node.getName() or "").strip()
except Exception:
node_name = ""
if node_name:
ref["node_name"] = node_name
if node_id:
return ref
# Fallback: track ancestor exported node + child name chain.
chain: List[str] = []
current = node
for _ in range(64):
if self._is_np_empty(current):
break
current_id = self._node_id_by_pointer.get(id(current))
if current_id:
ref["ancestor_node_id"] = current_id
chain.reverse()
if chain:
ref["child_name_chain"] = chain
break
try:
current_name = str(current.getName() or "").strip()
except Exception:
current_name = ""
if current_name:
chain.append(current_name)
try:
current = current.getParent()
except Exception:
break
return ref
@staticmethod
def _serialize_vec_like(value: Any) -> Optional[List[float]]:
get_components = []
for attr in ("getX", "getY", "getZ", "getW"):
getter = getattr(value, attr, None)
if callable(getter):
get_components.append(getter)
if not get_components:
return None
out: List[float] = []
for getter in get_components:
try:
out.append(float(getter()))
except Exception:
return None
return out
def _collect_ssbo_subnode_transform_overrides(self, model_node) -> Dict[str, Any]:
"""
Collect changed subnode transforms from SSBO controller runtime state.

View File

@ -971,6 +971,245 @@ function directionToThree(THREE, direction, basis) {
return d.normalize();
}
function applyShadowFlags(root, enabled) {
if (!root || !root.traverse) return;
root.traverse((obj) => {
if (obj.isMesh) {
obj.castShadow = !!enabled;
obj.receiveShadow = !!enabled;
}
});
}
function normalizeColorInput(color, fallback = [1, 1, 1]) {
const out = toColorArray(color, fallback);
if (out.some((v) => Number(v) > 1.0)) {
return out.map((v) => Number(v) / 255.0);
}
return out;
}
function mapToneMappingOperator(THREE, operator) {
const op = String(operator || "").trim().toLowerCase();
if (op === "none") return THREE.NoToneMapping;
if (op === "reinhard") return THREE.ReinhardToneMapping;
if (op === "exponential" || op === "exponential2") return THREE.CineonToneMapping;
// RenderPipeline's optimized/uncharted2 are filmic-like; ACES is the closest built-in curve.
return THREE.ACESFilmicToneMapping;
}
function clampPositive(value, fallback) {
const v = toFiniteNumber(value, fallback);
return Number.isFinite(v) && v > 0 ? v : fallback;
}
function applyRenderPipelineApproximation(THREE, renderer, scene, profile) {
const cfg = (profile && typeof profile === "object") ? profile : {};
const tone = (cfg.tone_mapping && typeof cfg.tone_mapping === "object") ? cfg.tone_mapping : {};
const shadows = (cfg.shadows && typeof cfg.shadows === "object") ? cfg.shadows : {};
const fog = (cfg.fog && typeof cfg.fog === "object") ? cfg.fog : {};
const bloom = (cfg.bloom && typeof cfg.bloom === "object") ? cfg.bloom : {};
// Keep lighting calibration closer to the editor defaults.
renderer.physicallyCorrectLights = false;
const useToneMapping = toBoolean(tone.web_use_tone_mapping, false);
if (useToneMapping) {
renderer.toneMapping = mapToneMappingOperator(THREE, tone.operator);
const webExposureBoost = Math.max(0.5, Math.min(3.0, toFiniteNumber(tone.web_exposure_boost, 1.0)));
renderer.toneMappingExposure = clampPositive(tone.exposure_scale, 1.0) * webExposureBoost;
} else {
renderer.toneMapping = THREE.NoToneMapping;
renderer.toneMappingExposure = 1.0;
}
const shadowEnabled = toBoolean(shadows.enabled, false) && toBoolean(shadows.web_enable, false);
const shadowRes = Math.max(256, Math.min(4096, Math.round(clampPositive(shadows.resolution, 1024))));
renderer.shadowMap.enabled = shadowEnabled;
renderer.shadowMap.type = toBoolean(shadows.use_pcf, true) ? THREE.PCFSoftShadowMap : THREE.PCFShadowMap;
let fogApplied = false;
if (toBoolean(fog.enabled, false)) {
const fogColor = normalizeColorInput(fog.color, [0.55, 0.6, 0.7]);
const fogIntensity = Math.max(0.0, toFiniteNumber(fog.intensity, 0));
const fogRamp = Math.max(1.0, toFiniteNumber(fog.ramp_size, 2000));
const density = Math.max(0.00001, Math.min(0.2, (fogIntensity / fogRamp) * 0.2));
scene.fog = new THREE.FogExp2(new THREE.Color(fogColor[0], fogColor[1], fogColor[2]), density);
fogApplied = true;
} else {
scene.fog = null;
}
return {
shadowEnabled,
shadowResolution: shadowRes,
toneMappingEnabled: useToneMapping,
fogApplied,
bloom: {
enabled: toBoolean(bloom.enabled, false),
strength: Math.max(0.0, toFiniteNumber(bloom.strength, 0)),
vendorAvailable: toBoolean(bloom.vendor_available, false),
mipmaps: Math.max(2, Math.min(10, Math.round(clampPositive(bloom.mipmaps, 6)))),
},
};
}
async function tryCreateBloomComposer(THREE, renderer, scene, camera, bloomConfig) {
const bloomEnabled = toBoolean(bloomConfig?.enabled, false);
const bloomStrength = Math.max(0.0, toFiniteNumber(bloomConfig?.strength, 0));
const vendorAvailable = toBoolean(bloomConfig?.vendorAvailable, true);
if (!bloomEnabled || bloomStrength <= 1e-4) {
return { composer: null, enabled: false, reason: "disabled" };
}
if (!vendorAvailable) {
return { composer: null, enabled: false, reason: "vendor_missing" };
}
try {
const [{ EffectComposer }, { RenderPass }, { UnrealBloomPass }] = await Promise.all([
import("../vendor/EffectComposer.js"),
import("../vendor/RenderPass.js"),
import("../vendor/UnrealBloomPass.js"),
]);
const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
const bloomPass = new UnrealBloomPass(
new THREE.Vector2(window.innerWidth, window.innerHeight),
bloomStrength,
0.35,
0.9,
);
composer.addPass(bloomPass);
return { composer, enabled: true, reason: "ok" };
} catch (err) {
console.warn("[WebGLPack] Bloom pass unavailable, fallback to base render:", err);
return { composer: null, enabled: false, reason: "module_missing" };
}
}
function applySkyboxConfig(THREE, scene, env, textureLoader) {
const sky = (env && typeof env.skybox === "object") ? env.skybox : null;
if (!sky || !toBoolean(sky.enabled, false) || !sky.uri) {
return Promise.resolve({ enabled: false, reason: "disabled", update: null });
}
const skyType = String(sky.type || "equirectangular").toLowerCase();
if (skyType !== "equirectangular" && skyType !== "skydome") {
console.warn("[WebGLPack] Unsupported skybox type:", skyType);
return Promise.resolve({ enabled: false, reason: "unsupported_type", update: null });
}
return new Promise((resolve) => {
textureLoader.load(
sky.uri,
(tex) => {
if ("colorSpace" in tex) {
tex.colorSpace = THREE.SRGBColorSpace;
} else if ("encoding" in tex) {
tex.encoding = THREE.sRGBEncoding;
}
tex.wrapS = THREE.RepeatWrapping;
tex.wrapT = THREE.ClampToEdgeWrapping;
if (skyType === "equirectangular") {
tex.mapping = THREE.EquirectangularReflectionMapping;
scene.background = tex;
if (toBoolean(sky.apply_environment, true)) {
scene.environment = tex;
}
resolve({ enabled: true, reason: "ok", update: null });
return;
}
// RenderPipeline default sky uses skydome projection (u = atan(x, y), v = z in Panda Z-up).
// In Web viewer (Three Y-up), equivalent direction is:
// u = atan(dir.x, -dir.z), v = dir.y.
const skyRadius = Math.max(1000, Number(sky.radius ?? 20000));
const clipLowerHemisphere = toBoolean(sky.clip_lower_hemisphere, true);
const lowerColor = toColorArray(sky.lower_hemisphere_color, [0.08, 0.10, 0.13]);
const horizonBlend = THREE.MathUtils.clamp(Number(sky.horizon_blend ?? 0.06), 0.001, 0.35);
const horizonSampleV = THREE.MathUtils.clamp(Number(sky.horizon_sample_v ?? 0.01), 0.0, 0.2);
const lowerTintStrength = THREE.MathUtils.clamp(Number(sky.lower_tint_strength ?? 0.65), 0.0, 1.0);
const skyGeom = new THREE.SphereGeometry(skyRadius, 32, 16);
const skyMat = new THREE.ShaderMaterial({
uniforms: {
uSkyTex: { value: tex },
uRotationU: { value: Number(sky.rotation_u ?? 0.0) },
uClipLowerHemisphere: { value: clipLowerHemisphere ? 1.0 : 0.0 },
uLowerColor: { value: new THREE.Color(lowerColor[0], lowerColor[1], lowerColor[2]) },
uHorizonBlend: { value: horizonBlend },
uHorizonSampleV: { value: horizonSampleV },
uLowerTintStrength: { value: lowerTintStrength },
},
vertexShader: `
uniform float uRotationU;
varying vec2 vSkyUv;
varying float vDirY;
const float PI = 3.1415926535897932384626433832795;
void main() {
vec3 dir = normalize(position.xyz);
vDirY = dir.y;
float u = (atan(dir.x, -dir.z) + PI) / (2.0 * PI);
vSkyUv = vec2(fract(u + uRotationU), clamp(dir.y, 0.0, 1.0));
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform sampler2D uSkyTex;
uniform float uClipLowerHemisphere;
uniform vec3 uLowerColor;
uniform float uHorizonBlend;
uniform float uHorizonSampleV;
uniform float uLowerTintStrength;
varying vec2 vSkyUv;
varying float vDirY;
void main() {
vec3 skyCol = texture2D(uSkyTex, vSkyUv).rgb;
if (uClipLowerHemisphere > 0.5) {
vec3 horizonCol = texture2D(uSkyTex, vec2(vSkyUv.x, uHorizonSampleV)).rgb;
vec3 lowerCol = mix(uLowerColor, horizonCol, uLowerTintStrength);
float t = smoothstep(-uHorizonBlend, uHorizonBlend, vDirY);
vec3 col = mix(lowerCol, skyCol, t);
gl_FragColor = vec4(col, 1.0);
} else {
gl_FragColor = vec4(skyCol, 1.0);
}
}
`,
side: THREE.BackSide,
depthWrite: false,
fog: false,
transparent: false,
});
skyMat.toneMapped = false;
const skydome = new THREE.Mesh(skyGeom, skyMat);
skydome.name = "WebGLPackSkydome";
skydome.matrixAutoUpdate = true;
skydome.frustumCulled = false;
scene.add(skydome);
if (!toBoolean(sky.apply_environment, false)) {
scene.environment = null;
}
const update = (camera) => {
if (!camera) return;
skydome.position.copy(camera.position);
};
resolve({ enabled: true, reason: "ok", update });
},
undefined,
(err) => {
console.warn("[WebGLPack] Skybox load failed:", sky.uri, err);
resolve({ enabled: false, reason: "load_failed", update: null });
},
);
});
}
function normalizeAnimationName(name) {
return canonicalizeNameSegment(String(name ?? ""));
}
@ -1055,6 +1294,465 @@ function setupModelAnimation(THREE, root, gltf, animationConfig, nodeName) {
};
}
function toFiniteNumber(value, fallback) {
const v = Number(value);
return Number.isFinite(v) ? v : fallback;
}
function toBoolean(value, fallback = true) {
if (typeof value === "boolean") return value;
if (typeof value === "number") return value !== 0;
if (typeof value === "string") {
const t = value.trim().toLowerCase();
if (["1", "true", "yes", "on"].includes(t)) return true;
if (["0", "false", "no", "off"].includes(t)) return false;
}
return fallback;
}
function parseColorVec4(raw, fallback = [1, 1, 1, 1]) {
if (Array.isArray(raw) && raw.length >= 3) {
return [
toFiniteNumber(raw[0], fallback[0]),
toFiniteNumber(raw[1], fallback[1]),
toFiniteNumber(raw[2], fallback[2]),
toFiniteNumber(raw[3], fallback[3]),
];
}
if (raw && typeof raw === "object") {
return [
toFiniteNumber(raw.r, fallback[0]),
toFiniteNumber(raw.g, fallback[1]),
toFiniteNumber(raw.b, fallback[2]),
toFiniteNumber(raw.a, fallback[3]),
];
}
return fallback.slice();
}
function normalizeScriptName(name) {
const text = String(name ?? "").trim().replace(/\.py$/i, "");
return canonicalizeNameSegment(text);
}
function markObjectTransformDirty(obj) {
if (!obj || !obj.isObject3D) return;
obj.updateMatrix();
obj.updateMatrixWorld(false);
}
function applyPandaAxisDeltaToThreeLocal(obj, axis, delta) {
const ax = String(axis || "x").toLowerCase();
if (ax === "x") {
obj.position.x += delta;
} else if (ax === "y") {
obj.position.z -= delta;
} else {
obj.position.y += delta;
}
}
function ensureUniqueMaterialsForObject(root) {
if (!root || !root.traverse) return;
root.traverse((obj) => {
if (!obj.isMesh || !obj.material) return;
if (Array.isArray(obj.material)) {
obj.material = obj.material.map((mat) => {
if (!mat) return mat;
if (mat.userData?.__webglPackUniqueClone) return mat;
const cloned = mat.clone();
cloned.userData = cloned.userData || {};
cloned.userData.__webglPackUniqueClone = true;
return cloned;
});
return;
}
const mat = obj.material;
if (!mat.userData?.__webglPackUniqueClone) {
const cloned = mat.clone();
cloned.userData = cloned.userData || {};
cloned.userData.__webglPackUniqueClone = true;
obj.material = cloned;
}
});
}
function applyColorToObject(root, rgba) {
if (!root || !root.traverse) return;
const r = toFiniteNumber(rgba?.[0], 1);
const g = toFiniteNumber(rgba?.[1], 1);
const b = toFiniteNumber(rgba?.[2], 1);
const a = Math.min(1, Math.max(0, toFiniteNumber(rgba?.[3], 1)));
root.traverse((obj) => {
if (!obj.isMesh || !obj.material) return;
const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
for (const mat of mats) {
if (!mat) continue;
if (mat.color) {
mat.color.setRGB(r, g, b);
}
if ("opacity" in mat) {
mat.opacity = a;
const transparent = a < 0.999;
mat.transparent = transparent;
mat.depthWrite = !transparent;
if (!transparent && "alphaTest" in mat) mat.alphaTest = 0;
}
mat.needsUpdate = true;
}
});
}
function hueToRgb(h) {
const hue = ((h % 1) + 1) % 1;
const i = Math.floor(hue * 6);
const f = hue * 6 - i;
const p = 0;
const q = 1 - f;
const t = f;
switch (i % 6) {
case 0: return [1, t, p];
case 1: return [q, 1, p];
case 2: return [p, 1, t];
case 3: return [p, q, 1];
case 4: return [t, p, 1];
default: return [1, p, q];
}
}
function buildNodeNameLookup(nodeMap) {
const byCanonicalName = new Map();
for (const obj of nodeMap.values()) {
const key = canonicalizeNameSegment(obj?.name ?? "");
if (!key) continue;
if (!byCanonicalName.has(key)) {
byCanonicalName.set(key, []);
}
byCanonicalName.get(key).push(obj);
}
return byCanonicalName;
}
function findDescendantByNameChain(root, chain) {
if (!root || !Array.isArray(chain) || chain.length === 0) return null;
let candidates = [root];
for (const segment of chain) {
const token = canonicalizeNameSegment(segment ?? "");
if (!token) continue;
const next = [];
for (const candidate of candidates) {
for (const child of candidate.children || []) {
const childToken = canonicalizeNameSegment(child?.name ?? "");
if (childToken === token) {
next.push(child);
}
}
}
if (next.length === 0) return null;
candidates = next;
}
return candidates[0] || null;
}
function resolveScriptNodeRef(refValue, nodeMap, nodeNameLookup) {
let ref = refValue;
if (ref && typeof ref === "object" && ref.__node_ref__ && typeof ref.__node_ref__ === "object") {
ref = ref.__node_ref__;
}
if (typeof ref === "string") {
const byId = nodeMap.get(ref);
if (byId) return byId;
const token = canonicalizeNameSegment(ref);
const byName = token ? (nodeNameLookup.get(token) || []) : [];
return byName.length > 0 ? byName[0] : null;
}
if (!ref || typeof ref !== "object") return null;
const nodeId = String(ref.node_id ?? "").trim();
if (nodeId && nodeMap.has(nodeId)) {
return nodeMap.get(nodeId);
}
const ancestorId = String(ref.ancestor_node_id ?? "").trim();
if (ancestorId && nodeMap.has(ancestorId) && Array.isArray(ref.child_name_chain)) {
const ancestor = nodeMap.get(ancestorId);
const desc = findDescendantByNameChain(ancestor, ref.child_name_chain);
if (desc) return desc;
}
const nodeName = String(ref.node_name ?? "").trim();
if (nodeName) {
const token = canonicalizeNameSegment(nodeName);
const byName = token ? (nodeNameLookup.get(token) || []) : [];
if (byName.length > 0) return byName[0];
}
return null;
}
function createScriptState(THREE, scriptCfg, object3d, nodeMap, nodeNameLookup, ownerName) {
const scriptName = String(scriptCfg?.name ?? "").trim();
if (!scriptName) return null;
const key = normalizeScriptName(scriptName);
const params = (scriptCfg?.params && typeof scriptCfg.params === "object") ? scriptCfg.params : {};
const enabled = toBoolean(scriptCfg?.enabled, true);
if (key === "moverscript" || key === "mover") {
const state = {
name: scriptName,
key,
enabled,
axis: String(params.move_axis ?? "x").toLowerCase(),
moveDistance: Math.max(0, Math.abs(toFiniteNumber(params.move_distance, 5.0))),
moveSpeed: toFiniteNumber(params.move_speed, 2.0),
currentDirection: toFiniteNumber(params.current_direction, 1) >= 0 ? 1 : -1,
currentDistance: Math.max(0, Math.abs(toFiniteNumber(params.current_distance, 0.0))),
isMoving: toBoolean(params.is_moving, true),
update(dt) {
if (!this.enabled || !this.isMoving) return;
const delta = this.moveSpeed * dt * this.currentDirection;
this.currentDistance += Math.abs(delta);
if (this.moveDistance > 1e-6 && this.currentDistance >= this.moveDistance) {
this.currentDirection *= -1;
this.currentDistance = 0;
}
applyPandaAxisDeltaToThreeLocal(object3d, this.axis, delta);
markObjectTransformDirty(object3d);
},
};
return state;
}
if (key === "rotatorscript" || key === "rotator") {
return {
name: scriptName,
key,
enabled,
speedDeg: toFiniteNumber(params.rotation_speed_y, 30.0),
isRotating: toBoolean(params.is_rotating, true),
update(dt) {
if (!this.enabled || !this.isRotating) return;
object3d.rotation.y += THREE.MathUtils.degToRad(this.speedDeg * dt);
markObjectTransformDirty(object3d);
},
};
}
if (key === "scalerscript" || key === "scaler") {
return {
name: scriptName,
key,
enabled,
baseScale: toFiniteNumber(params.base_scale, 1.0),
scaleAmplitude: toFiniteNumber(params.scale_amplitude, 0.3),
scaleSpeed: toFiniteNumber(params.scale_speed, 2.0),
uniformScale: toBoolean(params.uniform_scale, true),
timeAccumulator: toFiniteNumber(params.time_accumulator, 0.0),
isScaling: toBoolean(params.is_scaling, true),
update(dt) {
if (!this.enabled || !this.isScaling) return;
this.timeAccumulator += dt;
const sine = Math.sin(this.timeAccumulator * this.scaleSpeed * 2 * Math.PI);
const scaleFactor = this.baseScale + (this.scaleAmplitude * sine);
if (this.uniformScale) {
object3d.scale.setScalar(scaleFactor);
} else {
object3d.scale.y = scaleFactor;
}
markObjectTransformDirty(object3d);
},
};
}
if (key === "colorchangerscript" || key === "colorchanger") {
ensureUniqueMaterialsForObject(object3d);
return {
name: scriptName,
key,
enabled,
colorSpeed: toFiniteNumber(params.color_speed, 1.0),
colorMode: String(params.color_mode ?? "rainbow").toLowerCase(),
baseColor: parseColorVec4(params.base_color, [1, 1, 1, 1]),
intensity: toFiniteNumber(params.intensity, 1.0),
timeAccumulator: toFiniteNumber(params.time_accumulator, 0.0),
isChanging: toBoolean(params.is_changing, true),
update(dt) {
if (!this.enabled || !this.isChanging) return;
this.timeAccumulator += dt;
let rgba = this.baseColor.slice();
if (this.colorMode === "rainbow") {
const rgb = hueToRgb(this.timeAccumulator * this.colorSpeed);
rgba = [rgb[0] * this.intensity, rgb[1] * this.intensity, rgb[2] * this.intensity, 1.0];
} else if (this.colorMode === "pulse") {
const pulse = (Math.sin(this.timeAccumulator * this.colorSpeed * 2 * Math.PI) + 1.0) / 2.0;
const m = pulse * this.intensity;
rgba = [
this.baseColor[0] * m,
this.baseColor[1] * m,
this.baseColor[2] * m,
this.baseColor[3],
];
} else if (this.colorMode === "fade") {
const fade = (Math.sin(this.timeAccumulator * this.colorSpeed * 2 * Math.PI) + 1.0) / 2.0;
rgba = [
this.baseColor[0],
this.baseColor[1],
this.baseColor[2],
fade * this.intensity,
];
} else if (this.colorMode === "strobe") {
const safeSpeed = Math.max(0.0001, Math.abs(this.colorSpeed));
const interval = 1.0 / (safeSpeed * 2.0);
const on = (Math.floor(this.timeAccumulator / interval) % 2) === 0;
rgba = on
? [
this.baseColor[0] * this.intensity,
this.baseColor[1] * this.intensity,
this.baseColor[2] * this.intensity,
this.baseColor[3],
]
: [0.1, 0.1, 0.1, this.baseColor[3]];
}
applyColorToObject(object3d, rgba);
},
};
}
if (key === "bouncerscript" || key === "bouncer") {
return {
name: scriptName,
key,
enabled,
jumpHeight: toFiniteNumber(params.jump_height, 2.0),
jumpSpeed: toFiniteNumber(params.jump_speed, 3.0),
bounceType: String(params.bounce_type ?? "sine").toLowerCase(),
timeAccumulator: toFiniteNumber(params.time_accumulator, 0.0),
isBouncing: toBoolean(params.is_bouncing, true),
bounceDirection: toFiniteNumber(params.bounce_direction, 1) >= 0 ? 1 : -1,
originalHeight: toFiniteNumber(params.original_y, object3d.position.y),
update(dt) {
if (!this.enabled || !this.isBouncing) return;
this.timeAccumulator += dt * this.bounceDirection;
let offset = 0;
const sineValue = Math.sin(this.timeAccumulator * this.jumpSpeed * 2 * Math.PI);
if (this.bounceType === "sine") {
offset = sineValue * this.jumpHeight;
} else if (this.bounceType === "abs_sine") {
offset = Math.abs(sineValue) * this.jumpHeight;
} else if (this.bounceType === "square") {
offset = sineValue > 0 ? this.jumpHeight : 0;
}
object3d.position.y = this.originalHeight + offset;
markObjectTransformDirty(object3d);
},
};
}
if (key === "followerscript" || key === "follower") {
const targetHint = Object.prototype.hasOwnProperty.call(params, "target")
? params.target
: (Object.prototype.hasOwnProperty.call(params, "target_ref") ? params.target_ref : null);
return {
name: scriptName,
key,
enabled,
followSpeed: Math.max(0, toFiniteNumber(params.follow_speed, 5.0)),
followDistance: Math.max(0, toFiniteNumber(params.follow_distance, 2.0)),
isFollowing: toBoolean(params.is_following, true),
targetHint,
targetObject: null,
tempTargetPos: new THREE.Vector3(),
tempCurrentPos: new THREE.Vector3(),
tempMoveDir: new THREE.Vector3(),
update(dt) {
if (!this.enabled || !this.isFollowing) return;
if (!this.targetObject && this.targetHint) {
this.targetObject = resolveScriptNodeRef(this.targetHint, nodeMap, nodeNameLookup);
}
if (!this.targetObject) return;
this.targetObject.getWorldPosition(this.tempTargetPos);
object3d.getWorldPosition(this.tempCurrentPos);
this.tempMoveDir.subVectors(this.tempTargetPos, this.tempCurrentPos);
const distance = this.tempMoveDir.length();
if (distance > this.followDistance && distance > 1e-6) {
this.tempMoveDir.normalize();
const targetFollowPos = this.tempTargetPos.clone().addScaledVector(this.tempMoveDir, -this.followDistance);
const moveDirection = targetFollowPos.sub(this.tempCurrentPos);
const moveDistance = moveDirection.length();
if (moveDistance > 1e-6) {
moveDirection.normalize();
const moveAmount = Math.min(this.followSpeed * dt, moveDistance);
const newWorldPos = this.tempCurrentPos.clone().addScaledVector(moveDirection, moveAmount);
if (object3d.parent) {
object3d.parent.worldToLocal(newWorldPos);
}
object3d.position.copy(newWorldPos);
}
}
object3d.lookAt(this.tempTargetPos);
markObjectTransformDirty(object3d);
},
};
}
if (key === "comboanimatorscript" || key === "comboanimator") {
return {
name: scriptName,
key,
enabled,
time: toFiniteNumber(params.time, 0.0),
isActive: toBoolean(params.is_active, true),
originalHeight: object3d.position.y,
update(dt) {
if (!this.enabled || !this.isActive) return;
this.time += dt;
object3d.rotation.y += THREE.MathUtils.degToRad(45.0 * dt);
object3d.position.y = this.originalHeight + Math.abs(Math.sin(this.time * 3.0));
markObjectTransformDirty(object3d);
},
};
}
console.warn("[WebGLPack] Unsupported script in Web viewer:", {
node: ownerName || object3d?.name || "(unnamed)",
script: scriptName,
});
return null;
}
function buildScriptRuntimeStates(THREE, nodes, nodeMap) {
const runtimes = [];
let unsupportedCount = 0;
const nodeNameLookup = buildNodeNameLookup(nodeMap);
for (const node of nodes) {
const obj = nodeMap.get(node?.id);
if (!obj) continue;
const scripts = Array.isArray(node?.scripts) ? node.scripts : [];
for (const scriptCfg of scripts) {
const state = createScriptState(
THREE,
scriptCfg,
obj,
nodeMap,
nodeNameLookup,
node?.name || node?.id || "",
);
if (state) {
runtimes.push(state);
} else {
unsupportedCount += 1;
}
}
}
return { runtimes, unsupportedCount };
}
async function bootstrap() {
setStatus("Loading WebGL dependencies...");
@ -1090,6 +1788,7 @@ async function bootstrap() {
}
const data = await response.json();
const sceneNodes = Array.isArray(data.nodes) ? data.nodes : [];
const renderer = new THREE.WebGLRenderer({
canvas,
@ -1142,6 +1841,12 @@ async function bootstrap() {
}
const env = data.environment || {};
const rpProfile = (env.render_pipeline && typeof env.render_pipeline === "object")
? env.render_pipeline
: {};
const renderProfile = applyRenderPipelineApproximation(THREE, renderer, scene, rpProfile);
const textureLoader = new THREE.TextureLoader();
const skyboxPromise = applySkyboxConfig(THREE, scene, env, textureLoader);
if (env.ambient_light) {
const c = toColorArray(env.ambient_light.color, [0.2, 0.2, 0.2]);
@ -1159,6 +1864,10 @@ async function bootstrap() {
const dir = directionToThree(THREE, env.directional_light.direction, basis);
dirLight.position.copy(dir.clone().multiplyScalar(-40));
dirLight.target.position.set(0, 0, 0);
dirLight.castShadow = !!renderProfile.shadowEnabled;
if (dirLight.shadow && dirLight.shadow.mapSize) {
dirLight.shadow.mapSize.set(renderProfile.shadowResolution, renderProfile.shadowResolution);
}
scene.add(dirLight);
scene.add(dirLight.target);
@ -1170,9 +1879,8 @@ async function bootstrap() {
const animationStates = [];
const gltfLoader = new GLTFLoader();
const textureLoader = new THREE.TextureLoader();
for (const node of Array.isArray(data.nodes) ? data.nodes : []) {
for (const node of sceneNodes) {
let obj;
if (node.kind === "point_light") {
@ -1183,6 +1891,10 @@ async function bootstrap() {
Number(light.intensity ?? 1),
Number(light.range ?? 0),
);
obj.castShadow = !!renderProfile.shadowEnabled;
if (obj.shadow && obj.shadow.mapSize) {
obj.shadow.mapSize.set(renderProfile.shadowResolution, renderProfile.shadowResolution);
}
} else if (node.kind === "spot_light") {
const light = node.light || {};
const c = toColorArray(light.color, [1, 1, 1]);
@ -1195,6 +1907,10 @@ async function bootstrap() {
1 - Number(light.inner_cone_ratio ?? 0.4),
);
spot.target.position.set(0, 0, -1);
spot.castShadow = !!renderProfile.shadowEnabled;
if (spot.shadow && spot.shadow.mapSize) {
spot.shadow.mapSize.set(renderProfile.shadowResolution, renderProfile.shadowResolution);
}
obj = spot;
} else if (node.kind === "ground") {
const g = node.ground || {};
@ -1211,7 +1927,8 @@ async function bootstrap() {
side: THREE.DoubleSide,
});
obj = new THREE.Mesh(new THREE.PlaneGeometry(width, height), mat);
obj.receiveShadow = true;
obj.receiveShadow = !!renderProfile.shadowEnabled;
obj.castShadow = false;
} else {
obj = new THREE.Group();
const modelUri = node.model?.uri;
@ -1232,6 +1949,7 @@ async function bootstrap() {
);
applyMaterialOverride(THREE, root, node.material_override || null);
applyTextureOverrides(THREE, root, node.texture_overrides || [], textureLoader);
applyShadowFlags(root, renderProfile.shadowEnabled);
const anim = setupModelAnimation(
THREE,
root,
@ -1277,7 +1995,7 @@ async function bootstrap() {
nodeMap.set(node.id, obj);
}
for (const node of Array.isArray(data.nodes) ? data.nodes : []) {
for (const node of sceneNodes) {
const obj = nodeMap.get(node.id);
if (!obj) continue;
@ -1294,6 +2012,18 @@ async function bootstrap() {
}
await Promise.all(pendingModelLoads);
const skyboxState = await skyboxPromise;
const bloomSetup = await tryCreateBloomComposer(
THREE,
renderer,
scene,
camera,
renderProfile.bloom,
);
const composer = bloomSetup.composer;
const scriptRuntime = buildScriptRuntimeStates(THREE, sceneNodes, nodeMap);
const scriptStates = scriptRuntime.runtimes;
const unsupportedScriptCount = scriptRuntime.unsupportedCount;
const resize = () => {
const w = window.innerWidth;
@ -1301,13 +2031,16 @@ async function bootstrap() {
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
if (composer && typeof composer.setSize === "function") {
composer.setSize(w, h);
}
};
window.addEventListener("resize", resize);
resize();
setStatus(
`Scene ready. Nodes: ${(data.nodes || []).length}, Animations: ${animationStates.length}.\nUse mouse to orbit, wheel to zoom.`,
`Scene ready. Nodes: ${sceneNodes.length}, Animations: ${animationStates.length}, Scripts: ${scriptStates.length}${unsupportedScriptCount > 0 ? ` (unsupported: ${unsupportedScriptCount})` : ""}, Skybox: ${skyboxState.enabled ? "on" : "off"}, ToneMap: ${renderProfile.toneMappingEnabled ? "on" : "off"}, Shadows: ${renderProfile.shadowEnabled ? "on" : "off"}, Fog: ${renderProfile.fogApplied ? "on" : "off"}, Bloom: ${bloomSetup.enabled ? "on" : "off"}.\nUse mouse to orbit, wheel to zoom.`,
"ok",
);
@ -1323,8 +2056,23 @@ async function bootstrap() {
// Keep render loop alive even if one mixer fails.
}
}
scene.updateMatrixWorld(true);
for (const state of scriptStates) {
try {
state.update(dt);
} catch (err) {
// Keep render loop alive even if one script fails.
}
}
controls.update();
renderer.render(scene, camera);
if (skyboxState && typeof skyboxState.update === "function") {
skyboxState.update(camera);
}
if (composer) {
composer.render();
} else {
renderer.render(scene, camera);
}
}
};