web打包更新
This commit is contained in:
parent
681d13ea48
commit
d412c761b2
@ -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
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user