/** * SkyboxManager — кастомное небо для сцены (задача 16). * * Реализует процедурный gradient-skybox без внешних текстур (работает offline): * - купол-сфера (BACKSIDE) с ShaderMaterial: плавный градиент верх→низ, * солнечный диск, лёгкая дымка у горизонта; * - low-poly горы на горизонте (как в Roblox-эталоне); * - billboard-облака (плоскости, медленный дрейф); * - атмосферный туман (scene.fog). * * Пресеты: clear-summer-day / cloudy / sunset / starry-night / space / * lowpoly-roblox (+ gradient вручную). Плавный fadeTo между ними. * * API (через game.scene.*): * setSkybox({ preset } | { mode:'gradient', topColor, bottomColor, ... }) * setClouds({ enabled, cover, density, speed, color }) * setFog({ color, density, near, far } | enabled:false) * skybox.fadeTo(opts, durationSec) * skybox.setSunDirection({x,y,z}) * * Фича-парность: при портировании в плеер — тот же модуль в rublox-player/src/engine/. */ import { Color3, Color4, Vector3, MeshBuilder, ShaderMaterial, Effect, StandardMaterial, Texture, DynamicTexture, VertexData, Mesh, } from '@babylonjs/core'; // ── Шейдер градиентного неба ────────────────────────────────────────────── // Купол изнутри: цвет интерполируется по высоте (y от -1 низ до +1 верх), // плюс солнечный диск и осветление у горизонта (дымка). const SKY_VERT = ` precision highp float; attribute vec3 position; uniform mat4 worldViewProjection; varying vec3 vDir; void main(void){ vDir = normalize(position); gl_Position = worldViewProjection * vec4(position, 1.0); }`; const SKY_FRAG = ` precision highp float; varying vec3 vDir; uniform vec3 topColor; uniform vec3 bottomColor; uniform vec3 horizonColor; uniform vec3 sunDir; uniform vec3 sunColor; uniform float sunSize; // 0..1 угловой радиус uniform float horizonHaze; // 0..1 сила дымки у горизонта void main(void){ float h = clamp(vDir.y * 0.5 + 0.5, 0.0, 1.0); // 0 низ .. 1 верх // Градиент: низ→горизонт→верх (двойная интерполяция через горизонт у h~0.5) vec3 col; if (h < 0.5) { col = mix(bottomColor, horizonColor, h * 2.0); } else { col = mix(horizonColor, topColor, (h - 0.5) * 2.0); } // Дымка у горизонта — осветление узкой полосы около h=0.5 float haze = smoothstep(0.5, 0.0, abs(h - 0.5)) * horizonHaze; col = mix(col, horizonColor + vec3(0.08), haze * 0.5); // Солнечный диск + гало float d = distance(normalize(vDir), normalize(sunDir)); float disk = smoothstep(sunSize, sunSize * 0.4, d); float glow = smoothstep(sunSize * 6.0, 0.0, d) * 0.35; col += sunColor * disk; col += sunColor * glow; gl_FragColor = vec4(col, 1.0); }`; let _shaderRegistered = false; function registerSkyShader() { if (_shaderRegistered) return; Effect.ShadersStore['kubikonSkyVertexShader'] = SKY_VERT; Effect.ShadersStore['kubikonSkyFragmentShader'] = SKY_FRAG; _shaderRegistered = true; } const hexToRgb = (hex) => { if (Array.isArray(hex)) return hex; const h = String(hex || '#ffffff').replace('#', ''); return [ parseInt(h.substring(0, 2), 16) / 255, parseInt(h.substring(2, 4), 16) / 255, parseInt(h.substring(4, 6), 16) / 255, ]; }; // ── Пресеты неба ────────────────────────────────────────────────────────── // top/bottom/horizon — цвета купола; sun — направление/цвет/размер солнца; // mountains — рисовать ли горы; clouds — параметры облаков; fog — туман; // stars — звёздное небо (для ночи/космоса). const PRESETS = { 'clear-summer-day': { top: '#3d7fe0', horizon: '#bcd9f2', bottom: '#dcebf7', sunDir: [0.3, 0.85, 0.4], sunColor: '#fff6d8', sunSize: 0.035, haze: 0.6, mountains: false, clouds: { enabled: true, cover: 0.25, color: '#ffffff', speed: 0.015 }, fog: { color: '#cfe2f2', density: 0.0035 }, light: { sunIntensity: 1.0, sunColor: '#fff6e0', hemiIntensity: 0.7, ambient: '#b9d4ee' }, }, 'lowpoly-roblox': { top: '#4a93e6', horizon: '#cfe6f5', bottom: '#e6f1fa', sunDir: [0.4, 0.8, 0.45], sunColor: '#fffbe6', sunSize: 0.03, haze: 0.85, mountains: true, clouds: { enabled: true, cover: 0.45, color: '#ffffff', speed: 0.012 }, fog: { color: '#e2eef7', density: 0.005 }, light: { sunIntensity: 0.95, sunColor: '#fff7e0', hemiIntensity: 0.75, ambient: '#cfe6f5' }, }, 'cloudy': { top: '#8fa6bd', horizon: '#c2ccd6', bottom: '#d8dde2', sunDir: [0.2, 0.7, 0.3], sunColor: '#e8e8e8', sunSize: 0.0, haze: 0.4, mountains: false, clouds: { enabled: true, cover: 0.8, color: '#e8ebef', speed: 0.02 }, fog: { color: '#cfd6dd', density: 0.008 }, light: { sunIntensity: 0.5, sunColor: '#dfe4ea', hemiIntensity: 0.8, ambient: '#c2ccd6' }, }, 'sunset': { top: '#2a3a6b', horizon: '#f5915a', bottom: '#f7c98a', sunDir: [0.7, 0.12, -0.2], sunColor: '#ffd28a', sunSize: 0.05, haze: 1.0, mountains: true, clouds: { enabled: true, cover: 0.35, color: '#ffd9b3', speed: 0.01 }, fog: { color: '#f0b483', density: 0.006 }, light: { sunIntensity: 0.7, sunColor: '#ff9a52', hemiIntensity: 0.5, ambient: '#9a6a78' }, }, 'starry-night': { top: '#070b1f', horizon: '#1b2547', bottom: '#243056', sunDir: [-0.4, 0.75, 0.3], sunColor: '#cdd6ff', sunSize: 0.02, haze: 0.3, mountains: true, stars: true, clouds: { enabled: false }, fog: { color: '#141c38', density: 0.004 }, light: { sunIntensity: 0.18, sunColor: '#9aa8d8', hemiIntensity: 0.25, ambient: '#1b2547' }, }, 'space': { top: '#02030a', horizon: '#06070f', bottom: '#0a0c18', sunDir: [0.5, 0.6, 0.4], sunColor: '#ffffff', sunSize: 0.015, haze: 0.0, mountains: false, stars: true, clouds: { enabled: false }, fog: { enabled: false }, light: { sunIntensity: 0.6, sunColor: '#ffffff', hemiIntensity: 0.2, ambient: '#10121c' }, }, }; export class SkyboxManager { constructor(scene, hemiLight, sunLight) { this.scene = scene; this.hemiLight = hemiLight || null; // ambient this.sunLight = sunLight || null; // directional (тени) this._dome = null; this._mat = null; this._mountains = null; this._clouds = []; // [{mesh, baseX, speed}] this._cloudRoot = null; this._stars = null; this._fade = null; // активный fadeTo {from,to,t,dur} this._state = this._defaultState(); registerSkyShader(); this._buildDome(); } _defaultState() { return { mode: 'gradient', top: '#4a93e6', horizon: '#cfe6f5', bottom: '#e6f1fa', sunDir: [0.4, 0.8, 0.45], sunColor: '#fffbe6', sunSize: 0.03, haze: 0.8, mountains: false, stars: false, clouds: { enabled: false, cover: 0.4, color: '#ffffff', speed: 0.012 }, fog: { enabled: false, color: '#dde8f2', density: 0.005 }, light: { sunIntensity: 0.95, sunColor: '#fff7e0', hemiIntensity: 0.75, ambient: '#cfe6f5' }, }; } // ── Купол ────────────────────────────────────────────────────────────── _buildDome() { const dome = MeshBuilder.CreateSphere('kubikonSkyDome', { diameter: 1000, segments: 24, sideOrientation: Mesh.BACKSIDE, }, this.scene); dome.isPickable = false; dome.infiniteDistance = true; // не двигается с камерой dome.renderingGroupId = 0; dome.applyFog = false; const mat = new ShaderMaterial('kubikonSkyMat', this.scene, { vertex: 'kubikonSky', fragment: 'kubikonSky', }, { attributes: ['position'], uniforms: ['worldViewProjection', 'topColor', 'bottomColor', 'horizonColor', 'sunDir', 'sunColor', 'sunSize', 'horizonHaze'], }); mat.backFaceCulling = false; mat.disableDepthWrite = true; // небо всегда позади dome.material = mat; this._dome = dome; this._mat = mat; this._applyShaderUniforms(); } _applyShaderUniforms() { const s = this._state; const m = this._mat; if (!m) return; m.setColor3('topColor', Color3.FromArray(hexToRgb(s.top))); m.setColor3('bottomColor', Color3.FromArray(hexToRgb(s.bottom))); m.setColor3('horizonColor', Color3.FromArray(hexToRgb(s.horizon))); const sd = Array.isArray(s.sunDir) ? s.sunDir : [0.4, 0.8, 0.45]; m.setVector3('sunDir', new Vector3(sd[0], sd[1], sd[2]).normalize()); m.setColor3('sunColor', Color3.FromArray(hexToRgb(s.sunColor))); m.setFloat('sunSize', s.sunSize || 0.03); m.setFloat('horizonHaze', s.haze != null ? s.haze : 0.7); } // ── Горы (low-poly на горизонте) ──────────────────────────────────────── _buildMountains(colorHex) { this._disposeMountains(); const positions = [], indices = []; const ringR = 420, baseY = -10, segs = 64; // Кольцо из треугольных пиков переменной высоты — стилизованный силуэт. let vi = 0; for (let i = 0; i < segs; i++) { const a0 = (i / segs) * Math.PI * 2; const a1 = ((i + 1) / segs) * Math.PI * 2; const am = (a0 + a1) / 2; // Псевдослучайная высота пика (детерминированно от индекса). const hh = 40 + Math.abs(Math.sin(i * 1.7) * Math.cos(i * 0.6)) * 130; const x0 = Math.cos(a0) * ringR, z0 = Math.sin(a0) * ringR; const x1 = Math.cos(a1) * ringR, z1 = Math.sin(a1) * ringR; const xm = Math.cos(am) * ringR, zm = Math.sin(am) * ringR; positions.push(x0, baseY, z0, x1, baseY, z1, xm, baseY + hh, zm); indices.push(vi, vi + 1, vi + 2); vi += 3; } const vd = new VertexData(); vd.positions = positions; vd.indices = indices; const normals = []; VertexData.ComputeNormals(positions, indices, normals); vd.normals = normals; const mesh = new Mesh('kubikonSkyMountains', this.scene); vd.applyToMesh(mesh); mesh.isPickable = false; mesh.applyFog = true; // горы выцветают в туман (атмосфера) mesh.renderingGroupId = 0; const mat = new StandardMaterial('kubikonSkyMountainsMat', this.scene); const c = hexToRgb(colorHex || '#8fa98a'); mat.diffuseColor = new Color3(c[0], c[1], c[2]); mat.specularColor = new Color3(0, 0, 0); mat.emissiveColor = new Color3(c[0] * 0.25, c[1] * 0.25, c[2] * 0.25); mesh.material = mat; this._mountains = mesh; } _disposeMountains() { if (this._mountains) { this._mountains.material?.dispose(); this._mountains.dispose(); this._mountains = null; } } // ── Облака (billboard-плоскости) ──────────────────────────────────────── _buildClouds(opts) { this._disposeClouds(); const o = opts || {}; if (!o.enabled) return; const cover = o.cover != null ? o.cover : 0.4; const count = Math.round(4 + cover * 16); // 4..20 облаков const tex = this._makeCloudTexture(o.color || '#ffffff'); for (let i = 0; i < count; i++) { const w = 60 + Math.random() * 90; const plane = MeshBuilder.CreatePlane(`kubikonCloud_${i}`, { width: w, height: w * 0.55 }, this.scene); plane.billboardMode = Mesh.BILLBOARDMODE_ALL; plane.isPickable = false; plane.applyFog = false; plane.renderingGroupId = 0; const mat = new StandardMaterial(`kubikonCloudMat_${i}`, this.scene); mat.diffuseTexture = tex; mat.opacityTexture = tex; mat.emissiveColor = new Color3(1, 1, 1); mat.disableLighting = true; mat.backFaceCulling = false; plane.material = mat; const ang = Math.random() * Math.PI * 2; const rad = 150 + Math.random() * 200; const x = Math.cos(ang) * rad; const z = Math.sin(ang) * rad; const y = 90 + Math.random() * 70; plane.position.set(x, y, z); this._clouds.push({ mesh: plane, speed: (o.speed || 0.012) * (0.6 + Math.random() * 0.8) }); } } /** Процедурная мягкая текстура-облако (radial-gradient на DynamicTexture). */ _makeCloudTexture(colorHex) { const size = 256; const dt = new DynamicTexture('kubikonCloudTex', { width: size, height: size }, this.scene, false); const ctx = dt.getContext(); ctx.clearRect(0, 0, size, size); const c = hexToRgb(colorHex); const rgb = `${Math.round(c[0] * 255)},${Math.round(c[1] * 255)},${Math.round(c[2] * 255)}`; // Несколько перекрывающихся мягких кругов → пухлое облако. const blobs = [[128, 140, 70], [90, 150, 50], [170, 150, 55], [128, 110, 55], [110, 130, 45], [150, 130, 45]]; for (const [bx, by, br] of blobs) { const g = ctx.createRadialGradient(bx, by, 0, bx, by, br); g.addColorStop(0, `rgba(${rgb},0.9)`); g.addColorStop(0.6, `rgba(${rgb},0.5)`); g.addColorStop(1, `rgba(${rgb},0)`); ctx.fillStyle = g; ctx.beginPath(); ctx.arc(bx, by, br, 0, Math.PI * 2); ctx.fill(); } dt.hasAlpha = true; dt.update(); return dt; } _disposeClouds() { for (const c of this._clouds) { c.mesh.material?.diffuseTexture?.dispose?.(); c.mesh.material?.dispose(); c.mesh.dispose(); } this._clouds = []; } // ── Звёзды (точки на куполе) ───────────────────────────────────────────── _buildStars(enabled) { this._disposeStars(); if (!enabled) return; const size = 1024; const dt = new DynamicTexture('kubikonStarsTex', { width: size, height: size }, this.scene, false); const ctx = dt.getContext(); ctx.fillStyle = 'rgba(0,0,0,0)'; ctx.clearRect(0, 0, size, size); for (let i = 0; i < 600; i++) { const x = Math.random() * size, y = Math.random() * size; const r = Math.random() * 1.4 + 0.3; const a = 0.4 + Math.random() * 0.6; ctx.fillStyle = `rgba(255,255,255,${a})`; ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill(); } dt.hasAlpha = true; dt.update(); const dome = MeshBuilder.CreateSphere('kubikonStarsDome', { diameter: 980, segments: 16, sideOrientation: Mesh.BACKSIDE, }, this.scene); dome.isPickable = false; dome.infiniteDistance = true; dome.applyFog = false; dome.renderingGroupId = 0; const mat = new StandardMaterial('kubikonStarsMat', this.scene); mat.diffuseTexture = dt; mat.opacityTexture = dt; mat.emissiveColor = new Color3(1, 1, 1); mat.disableLighting = true; mat.backFaceCulling = false; mat.disableDepthWrite = true; dome.material = mat; this._stars = dome; } _disposeStars() { if (this._stars) { this._stars.material?.diffuseTexture?.dispose?.(); this._stars.material?.dispose(); this._stars.dispose(); this._stars = null; } } // ── Туман ──────────────────────────────────────────────────────────────── _applyFog(fog) { if (!this.scene) return; if (fog && fog.enabled !== false && (fog.density != null || fog.color)) { this.scene.fogMode = 2; // EXP const c = hexToRgb(fog.color || '#dde8f2'); this.scene.fogColor = new Color3(c[0], c[1], c[2]); this.scene.fogDensity = fog.density != null ? fog.density : 0.005; } else if (fog && fog.enabled === false) { this.scene.fogMode = 0; } } // ── Освещение (единый источник: небо управляет светом сцены) ───────────── /** Выставить направление/яркость солнца и ambient под текущее небо. */ _applyLighting(light, sunDir) { if (this.sunLight && sunDir) { // DirectionalLight.direction указывает КУДА падает свет → от солнца вниз. const d = new Vector3(-sunDir[0], -sunDir[1], -sunDir[2]); if (d.lengthSquared() > 0) { d.normalize(); this.sunLight.direction = d; } } if (!light) return; if (this.sunLight) { if (typeof light.sunIntensity === 'number') this.sunLight.intensity = light.sunIntensity; if (light.sunColor) this.sunLight.diffuse = Color3.FromArray(hexToRgb(light.sunColor)); } if (this.hemiLight) { if (typeof light.hemiIntensity === 'number') this.hemiLight.intensity = light.hemiIntensity; if (light.ambient) this.hemiLight.groundColor = Color3.FromArray(hexToRgb(light.ambient)); } } // ── Public API ─────────────────────────────────────────────────────────── /** Применить пресет или ручные опции gradient. */ setSkybox(opts) { if (!opts) return; const preset = opts.preset && PRESETS[opts.preset] ? { ...PRESETS[opts.preset] } : null; const s = this._state; if (preset) { s.top = preset.top; s.horizon = preset.horizon; s.bottom = preset.bottom; s.sunDir = preset.sunDir; s.sunColor = preset.sunColor; s.sunSize = preset.sunSize; s.haze = preset.haze; s.mountains = !!preset.mountains; s.stars = !!preset.stars; s.clouds = { ...(preset.clouds || { enabled: false }) }; s.fog = { enabled: preset.fog?.enabled !== false, ...(preset.fog || {}) }; s.light = preset.light || null; this._applyLighting(preset.light, preset.sunDir); } else { // Ручной gradient: { topColor, bottomColor, horizonColor, sunDirection, sunColor, sunSize } if (opts.topColor) s.top = opts.topColor; if (opts.bottomColor) s.bottom = opts.bottomColor; if (opts.horizonColor) s.horizon = opts.horizonColor; if (opts.sunDirection) s.sunDir = [opts.sunDirection.x, opts.sunDirection.y, opts.sunDirection.z]; if (opts.sunColor) s.sunColor = opts.sunColor; if (typeof opts.sunSize === 'number') s.sunSize = opts.sunSize; if (typeof opts.haze === 'number') s.haze = opts.haze; if (typeof opts.mountains === 'boolean') s.mountains = opts.mountains; if (typeof opts.stars === 'boolean') s.stars = opts.stars; } this._rebuildAll(); } /** Облака поверх любого режима. */ setClouds(opts) { if (!opts) return; this._state.clouds = { ...this._state.clouds, ...opts }; if (this._state.clouds.enabled == null) this._state.clouds.enabled = true; this._buildClouds(this._state.clouds); } /** Атмосферный туман. */ setFog(opts) { if (!opts) { return; } this._state.fog = { ...this._state.fog, ...opts }; if (opts.enabled == null) this._state.fog.enabled = true; this._applyFog(this._state.fog); } /** Установить направление солнца (для программной анимации). */ setSunDirection(dir) { if (!dir) return; this._state.sunDir = [dir.x, dir.y, dir.z]; this._applyShaderUniforms(); } /** Плавный переход к пресету/опциям за durationSec секунд (цвета купола + туман). */ fadeTo(opts, durationSec = 2) { const target = opts?.preset && PRESETS[opts.preset] ? { ...PRESETS[opts.preset] } : null; if (!target) { this.setSkybox(opts); return; } // Запоминаем стартовые цвета и целевые — анимируем в tick(). this._fade = { t: 0, dur: Math.max(0.1, durationSec), from: { top: hexToRgb(this._state.top), horizon: hexToRgb(this._state.horizon), bottom: hexToRgb(this._state.bottom), sunColor: hexToRgb(this._state.sunColor), sunDir: this._state.sunDir.slice(), sunSize: this._state.sunSize, haze: this._state.haze, }, to: { top: hexToRgb(target.top), horizon: hexToRgb(target.horizon), bottom: hexToRgb(target.bottom), sunColor: hexToRgb(target.sunColor), sunDir: target.sunDir.slice(), sunSize: target.sunSize, haze: target.haze, }, target, }; // Немедленно перестраиваем «дискретные» части (горы/звёзды/облака/туман // целевого пресета появляются сразу, цвета купола — плавно). const s = this._state; s.mountains = !!target.mountains; s.stars = !!target.stars; s.clouds = { ...(target.clouds || { enabled: false }) }; s.fog = { enabled: target.fog?.enabled !== false, ...(target.fog || {}) }; s.light = target.light || null; this._rebuildExtras(); // Запоминаем стартовые/целевые значения света для плавной анимации. if (target.light) { this._fade.lightFrom = { sunInt: this.sunLight?.intensity ?? 1, hemiInt: this.hemiLight?.intensity ?? 0.7, }; this._fade.lightTo = { sunInt: target.light.sunIntensity ?? 1, hemiInt: target.light.hemiIntensity ?? 0.7, sunColor: target.light.sunColor, ambient: target.light.ambient, }; } } /** Пересобрать всё (купол-uniforms + горы + звёзды + облака + туман + свет). */ _rebuildAll() { this._applyShaderUniforms(); this._rebuildExtras(); this._applyLighting(this._state.light, this._state.sunDir); } _rebuildExtras() { const s = this._state; if (s.mountains) { // Цвет гор подбираем под пресет (днём зеленовато-серый, ночью тёмный). const mc = s.stars ? '#2a3550' : '#8fa98a'; this._buildMountains(mc); } else this._disposeMountains(); this._buildStars(!!s.stars); this._buildClouds(s.clouds); this._applyFog(s.fog); } /** Вызывать каждый кадр: дрейф облаков + анимация fadeTo. */ tick(dt) { // Дрейф облаков по кругу. for (const c of this._clouds) { c.mesh.position.x += c.speed * dt * 60; if (c.mesh.position.x > 380) c.mesh.position.x = -380; } // Анимация перехода неба. if (this._fade) { this._fade.t += dt; const k = Math.min(1, this._fade.t / this._fade.dur); const f = this._fade.from, t = this._fade.to, m = this._mat; const mix = (a, b) => [a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k, a[2] + (b[2] - a[2]) * k]; if (m) { m.setColor3('topColor', Color3.FromArray(mix(f.top, t.top))); m.setColor3('bottomColor', Color3.FromArray(mix(f.bottom, t.bottom))); m.setColor3('horizonColor', Color3.FromArray(mix(f.horizon, t.horizon))); m.setColor3('sunColor', Color3.FromArray(mix(f.sunColor, t.sunColor))); const sd = mix(f.sunDir, t.sunDir); m.setVector3('sunDir', new Vector3(sd[0], sd[1], sd[2]).normalize()); m.setFloat('sunSize', f.sunSize + (t.sunSize - f.sunSize) * k); m.setFloat('horizonHaze', f.haze + (t.haze - f.haze) * k); // Плавно ведём направление солнца (свет) к целевому (используем sd выше). if (this.sunLight) { const d = new Vector3(-sd[0], -sd[1], -sd[2]); if (d.lengthSquared() > 0) { d.normalize(); this.sunLight.direction = d; } } } // Плавно ведём яркость/ambient света. if (this._fade.lightFrom && this._fade.lightTo) { const lf = this._fade.lightFrom, lt = this._fade.lightTo; if (this.sunLight) { this.sunLight.intensity = lf.sunInt + (lt.sunInt - lf.sunInt) * k; if (lt.sunColor) this.sunLight.diffuse = Color3.FromArray(hexToRgb(lt.sunColor)); } if (this.hemiLight) { this.hemiLight.intensity = lf.hemiInt + (lt.hemiInt - lf.hemiInt) * k; if (lt.ambient) this.hemiLight.groundColor = Color3.FromArray(hexToRgb(lt.ambient)); } } if (k >= 1) { // Зафиксировать целевое состояние в _state (как hex). const tp = this._fade.target; Object.assign(this._state, { top: tp.top, horizon: tp.horizon, bottom: tp.bottom, sunColor: tp.sunColor, sunDir: tp.sunDir.slice(), sunSize: tp.sunSize, haze: tp.haze, }); this._fade = null; } } } serialize() { return { ...this._state, _active: true }; } load(data) { if (!data) return; this._state = { ...this._defaultState(), ...data }; this._rebuildAll(); } dispose() { this._disposeMountains(); this._disposeClouds(); this._disposeStars(); if (this._dome) { this._mat?.dispose(); this._dome.dispose(); this._dome = null; } } }