feat(stage-ui): Switch between hemisphere light and sky box (#357)

This commit is contained in:
Lilia_Chen
2025-08-14 22:15:21 +08:00
committed by GitHub
parent 93d5f5adf0
commit 92ae0c0730
7 changed files with 354 additions and 60 deletions
@@ -0,0 +1,146 @@
<script setup lang="ts">
import { computed } from 'vue'
type ThemeVariant = 'primary' | 'violet' | 'lime' | 'orange'
export interface TabItem {
value: string
label: string
icon?: string | { active: string, idle: string }
disabled?: boolean
}
const props = withDefaults(defineProps<{
modelValue: string
tabs: TabItem[]
theme?: ThemeVariant
size?: 'xs' | 'sm' | 'md'
label?: string
}>(), {
theme: 'primary',
size: 'sm',
})
const emit = defineEmits<{
(e: 'update:modelValue', v: string): void
(e: 'change', v: string): void
}>()
const commonThemeClasses = {
tabActive: [
'bg-white shadow-sm font-bold text-blue-600 dark:text-blue-400 text-xs',
'ring-2 ring-blue-500',
'dark:bg-neutral-700',
'ring-1 ring-black/5 dark:ring-white/10',
],
tabIdle: [
'text-neutral-600 hover:bg-white/70',
'dark:text-neutral-300 dark:hover:bg-white/10',
'text-xs',
],
}
const themeClasses: Record<ThemeVariant, {
container: string[]
tabActive: string[]
tabIdle: string[]
label?: string[]
}> = {
primary: {
container: [
'bg-primary-50/60 dark:bg-primary-900/25 backdrop-blur-md',
'text-neutral-700/80 dark:text-neutral-300/80',
],
label: ['text-primary-500 dark:text-primary-400 font-semibold'],
...commonThemeClasses,
},
violet: {
container: [
'bg-violet-50/60 dark:bg-violet-900/25 backdrop-blur-md',
'text-neutral-700/80 dark:text-neutral-300/80',
],
label: ['text-violet-500 dark:text-violet-400 font-semibold'],
...commonThemeClasses,
},
lime: {
container: [
'bg-lime-50/60 dark:bg-lime-900/25 backdrop-blur-md',
'text-neutral-700/80 dark:text-neutral-300/80',
],
label: ['text-lime-500 dark:text-lime-400 font-semibold'],
...commonThemeClasses,
},
orange: {
container: [
'bg-orange-50/70 dark:bg-orange-900/25 backdrop-blur-md',
'text-neutral-700/80 dark:text-neutral-300/80',
],
label: ['text-orange-500 dark:text-orange-400 font-semibold'],
...commonThemeClasses,
},
}
const sizeCls = computed(() => ({
xs: 'px-2 py-1 text-xs',
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-sm',
})[props.size])
function onClick(v: string, disabled?: boolean) {
if (disabled)
return
if (v === props.modelValue)
return
emit('update:modelValue', v)
emit('change', v)
}
</script>
<template>
<div
class="relative overflow-hidden rounded-lg p-2"
:class="themeClasses[theme].container"
>
<div v-if="label" class="mb-1 text-sm" :class="themeClasses[theme].label">
{{ label }}
</div>
<div class="flex select-none items-center gap-1" role="tablist">
<button
v-for="t in tabs"
:key="t.value"
type="button"
role="tab"
:aria-selected="modelValue === t.value"
:disabled="t.disabled"
:class="[
'inline-flex items-center gap-1 rounded-md transition-all',
sizeCls,
t.disabled ? 'opacity-40 cursor-not-allowed' : '',
modelValue === t.value ? themeClasses[theme].tabActive : themeClasses[theme].tabIdle,
]"
@click="onClick(t.value, t.disabled)"
>
<span
v-if="t.icon"
:class="[
typeof t.icon === 'string'
? t.icon
: (t.value === modelValue ? t.icon.active : t.icon.idle),
'text-base',
]"
/>
<span class="whitespace-nowrap">{{ t.label }}</span>
</button>
</div>
<!-- Content -->
<div class="mt-2">
<!-- Pass active value to consumer -->
<slot name="default" :active="modelValue" />
</div>
</div>
</template>
<style scoped>
button:focus-visible { outline: 2px solid rgb(147 197 253); outline-offset: 2px }
</style>
@@ -1,3 +1,4 @@
export { default as Callout } from './Callout.vue'
export { default as PageHeader } from './PageHeader.vue'
export { default as Section } from './Section.vue'
export { default as Tabs } from './Tabs.vue'
@@ -7,7 +7,7 @@ import { useI18n } from 'vue-i18n'
import { useVRM } from '../../../../stores'
import { Container, PropertyColor, PropertyNumber, PropertyPoint } from '../../../DataPane'
import { Callout } from '../../../Layouts'
import { Callout, Tabs } from '../../../Layouts'
import { Button } from '../../../Misc'
import { ColorPalette } from '../../../Widgets'
@@ -50,7 +50,10 @@ const {
hemisphereLightIntensity,
hemisphereSkyColor,
hemisphereGroundColor,
envSelect,
} = storeToRefs(vrm)
const { defaultModelUrl } = vrm // 普通字符串
const trackingOptions = computed(() => [
{ value: 'camera', label: t('settings.vrm.scale-and-position.eye-tracking-mode.options.option.camera'), class: 'col-start-3' },
@@ -72,12 +75,48 @@ const urlRef = computed({
},
})
function handleUrlLoad() {
const parsedUrl = new URL(urlRef.value, 'https://example.com')
if (parsedUrl.origin === 'https://example.com') {
modelUrl.value = urlRef.value
async function handleUrlLoad() {
const raw = (modelUrl.value || '').trim()
// If empty input, reset to default
if (!raw) {
modelFile.value = null
urlRef.value = defaultModelUrl
return
}
// 3) parse URL
let parsedUrl: URL
try {
parsedUrl = new URL(raw, window.location.origin)
}
catch {
console.warn('Illegal URL input')
return
}
if (!['http:', 'https:', 'blob:', 'data:'].includes(parsedUrl.protocol))
return
modelUrl.value = parsedUrl.href
}
// switch between hemisphere light and sky box
const tabList = [
{
value: 'hemisphere',
label: 'Hemisphere',
icon: {
idle: 'i-solar:forbidden-circle-linear rotate-45',
active: 'i-solar:forbidden-circle-bold rotate-45',
},
},
{
value: 'skyBox',
label: 'SkyBox',
icon: {
idle: 'i-solar:gallery-circle-linear',
active: 'i-solar:gallery-circle-bold',
},
},
]
</script>
<template>
@@ -186,29 +225,42 @@ function handleUrlLoad() {
v-model="ambientLightColor"
label="Ambient Light Color"
/>
<PropertyPoint
v-model:x="hemisphereLightPosition.x"
v-model:y="hemisphereLightPosition.y"
v-model:z="hemisphereLightPosition.z"
label="Hemisphere Light Position"
:x-config="{ step: 0.001, label: 'X', formatValue: val => val?.toFixed(4) }"
:y-config="{ step: 0.001, label: 'Y', formatValue: val => val?.toFixed(4) }"
:z-config="{ step: 0.001, label: 'Z', formatValue: val => val?.toFixed(4) }"
/>
<PropertyNumber
v-model="hemisphereLightIntensity"
:config="{ min: 0, max: 10, step: 0.01, label: 'Intensity' }"
label="Hemisphere Light Intensity"
/>
<PropertyColor
v-model="hemisphereSkyColor"
label="Hemisphere Sky Color"
/>
<PropertyColor
v-model="hemisphereGroundColor"
label="Hemisphere Ground Color"
/>
</div>
<div>
<Tabs v-model="envSelect" :tabs="tabList" label="Environment">
<template #default>
<div v-if="envSelect === 'hemisphere'">
<!-- hemisphere settings -->
<div grid="~ cols-5 gap-1" p-2>
<PropertyPoint
v-model:x="hemisphereLightPosition.x"
v-model:y="hemisphereLightPosition.y"
v-model:z="hemisphereLightPosition.z"
label="Hemisphere Light Position"
:x-config="{ step: 0.001, label: 'X', formatValue: val => val?.toFixed(4) }"
:y-config="{ step: 0.001, label: 'Y', formatValue: val => val?.toFixed(4) }"
:z-config="{ step: 0.001, label: 'Z', formatValue: val => val?.toFixed(4) }"
/>
<PropertyNumber
v-model="hemisphereLightIntensity"
:config="{ min: 0, max: 10, step: 0.01, label: 'Intensity' }"
label="Hemisphere Light Intensity"
/>
<PropertyColor
v-model="hemisphereSkyColor"
label="Hemisphere Sky Color"
/>
<PropertyColor
v-model="hemisphereGroundColor"
label="Hemisphere Ground Color"
/>
</div>
</div>
<div v-else>
<!-- skybox settings -->
</div>
</template>
</Tabs>
</div>
</Container>
<Container
@@ -6,7 +6,9 @@ import { formatHex } from 'culori'
import { storeToRefs } from 'pinia'
import { BlendFunction } from 'postprocessing'
import { ACESFilmicToneMapping, PerspectiveCamera, Plane, Raycaster, Vector2, Vector3 } from 'three'
import { onUnmounted, ref, shallowRef, watch } from 'vue'
import { ref, shallowRef, watch } from 'vue'
import Environment from './VRM/Environment.vue'
import { useVRM } from '../../stores'
import { OrbitControls, VRMModel } from '../Scenes'
@@ -46,6 +48,9 @@ const {
hemisphereLightIntensity,
hemisphereSkyColor,
hemisphereGroundColor,
envSelect,
skyBoxSrc,
} = storeToRefs(useVRM())
const modelRef = ref<InstanceType<typeof VRMModel>>()
@@ -110,10 +115,6 @@ watch(() => controlsRef.value?.controls, (ctrl) => {
}
ctrl.addEventListener('change', updateCameraFromControls)
onUnmounted(() => {
ctrl.removeEventListener('change', updateCameraFromControls)
})
}
})
@@ -258,18 +259,24 @@ defineExpose({
:tone-mapping-exposure="1"
>
<OrbitControls ref="controlsRef" />
<TresAmbientLight
:color="formatHex(ambientLightColor)"
:intensity="ambientLightIntensity"
cast-shadow
<Environment
v-if="envSelect === 'skyBox'"
:sky-box-src="skyBoxSrc"
:as-background="true"
/>
<TresHemisphereLight
v-else
:color="formatHex(hemisphereSkyColor)"
:ground-color="formatHex(hemisphereGroundColor)"
:position="[hemisphereLightPosition.x, hemisphereLightPosition.y, hemisphereLightPosition.z]"
:intensity="hemisphereLightIntensity"
cast-shadow
/>
<TresAmbientLight
:color="formatHex(ambientLightColor)"
:intensity="ambientLightIntensity"
cast-shadow
/>
<TresDirectionalLight
:color="formatHex(directionalLightColor)"
:position="[directionalLightPosition.x, directionalLightPosition.y, directionalLightPosition.z]"
@@ -1,39 +1,97 @@
<script setup lang="ts">
import type { CanvasTexture, DataTexture } from 'three'
import type { CanvasTexture, DataTexture, Scene, WebGLRenderer, WebGLRenderTarget } from 'three'
import { useTresContext } from '@tresjs/core'
import { EquirectangularReflectionMapping } from 'three'
import {
ACESFilmicToneMapping,
PMREMGenerator,
SRGBColorSpace,
} from 'three'
import { RGBELoader } from 'three/examples/jsm/loaders/RGBELoader.js'
import { onMounted, ref } from 'vue'
import { onMounted, onUnmounted, ref, watch } from 'vue'
import defaultSkyBoxHdri from '../Tres/assets/sky_linekotsi_23_HDRI.hdr?url'
import defaultskyBoxSrc from '../Tres/assets/sky_linekotsi_23_HDRI.hdr?url'
/**
* Props
* - src: HDRI url; when falsy ('' | null | undefined) the environment is cleared but component stays mounted.
* - asBackground: whether to also use as scene.background.
* - backgroundBlurriness / backgroundIntensity: r152+ background controls.
*/
const props = withDefaults(defineProps<{
skyBoxSrc?: string | null
asBackground?: boolean
backgroundBlurriness?: number
backgroundIntensity?: number
}>(), {
skyBoxSrc: defaultskyBoxSrc,
asBackground: true,
backgroundBlurriness: 0,
backgroundIntensity: 1,
})
// Add these reactive variables to your setup
const environment = ref<DataTexture | CanvasTexture>()
const backgroundBlur = ref(0) // Controls background blur
const backgroundIntensity = ref(1) // Controls background visibility
let _pmrem: PMREMGenerator | null = null
let _envRT: WebGLRenderTarget | null = null // WebGLRenderTarget from PMREM
const { scene } = useTresContext()
const { scene, renderer } = useTresContext()
// Remove the sky box
function clearEnvironment() {
const scn = scene.value as Scene | null
if (!scn)
return
scn.environment = null
scn.background = null
// Do not forcibly clear background; caller/prop controls that intent.
// scn.background = null
_envRT?.dispose?.()
_pmrem?.dispose?.()
_envRT = null
_pmrem = null
}
// load HDRI environment from sky box
async function loadEnvironment(skyBoxSrc?: string | null) {
if (!scene.value || !renderer.value)
return
// Always dispose previous env when switching
clearEnvironment()
// If no designated sky box, then load the default one
if (!skyBoxSrc) {
skyBoxSrc = defaultskyBoxSrc
}
// Recommended renderer configuration for HDR + PBR
renderer.value.outputColorSpace = SRGBColorSpace
// This tone mapping is only for BPR parts, for NPR parts, this mapping will be disabled when loading model (as per parts)
renderer.value.toneMapping = ACESFilmicToneMapping
// Add this function to load HDRI environment
async function loadEnvironment() {
try {
const loader = new RGBELoader()
// Resources
// - polyhaven.com
// - hdrihaven.com
const texture = await loader.loadAsync(defaultSkyBoxHdri)
texture.mapping = EquirectangularReflectionMapping
const hdrTex = await new RGBELoader().loadAsync(skyBoxSrc)
environment.value = texture
// PMREM prefiltering for physically correct IBL
_pmrem = new PMREMGenerator(renderer.value as WebGLRenderer)
const rt = _pmrem.fromEquirectangular(hdrTex)
_envRT = rt
// Apply to scene if renderer is available
if (scene.value) {
scene.value.environment = texture
scene.value.background = texture
scene.value.backgroundBlurriness = backgroundBlur.value
scene.value.backgroundIntensity = backgroundIntensity.value
}
environment.value = hdrTex
const scn = scene.value as Scene
scn.environment = rt.texture // drives PBR materials (Standard/Physical)
if (props.asBackground)
scn.background = rt.texture // optional: also show as background
// r152+: background controls (no-ops on older versions)
scn.backgroundBlurriness = props.backgroundBlurriness
scn.backgroundIntensity = props.backgroundIntensity
// Source HDR no longer needed for sampling by materials
hdrTex.dispose()
}
catch (error) {
console.warn('Failed to load HDRI environment:', error)
@@ -42,7 +100,23 @@ async function loadEnvironment() {
// Usage in your component
onMounted(async () => {
await loadEnvironment()
// load environment from sky box (default or user input)
await loadEnvironment(props.skyBoxSrc)
// hot switch: react to sky box change
watch(
// Actually we can also let user set background blurriness or intensity
// TODO: maybe we can open more options for users regarding to the settings of the background in the future
() => [props.skyBoxSrc],
([skyBoxSrc]) => {
loadEnvironment(skyBoxSrc as string | null)
},
{ deep: false },
)
})
onUnmounted(async () => {
await clearEnvironment()
})
</script>
@@ -206,6 +206,7 @@ async function loadModel() {
if (child instanceof Mesh && child.material) {
const material = Array.isArray(child.material) ? child.material : [child.material]
material.forEach((mat) => {
// console.debug('material: ', mat)
if (mat instanceof MeshStandardMaterial || mat instanceof MeshPhysicalMaterial) {
// Should read envMap intensity from outside props
mat.envMapIntensity = 1.0
@@ -273,8 +274,10 @@ watch(modelRotationY, (newRotationY) => {
vrmGroup.value.rotation.y = MathUtils.degToRad(newRotationY)
}
})
watch(modelSrcNormalized, (newSrc) => {
if (newSrc) {
// watch if the model needs to be reloaded
watch(modelSrcNormalized, (newSrc, oldSrc) => {
if (newSrc !== oldSrc) {
loadModel()
}
})
+11
View File
@@ -4,6 +4,8 @@ import { useLocalStorage } from '@vueuse/core'
import { defineStore } from 'pinia'
import { computed, onMounted, ref } from 'vue'
import defaultSkyBoxSrc from '../components/Scenes/Tres/assets/sky_linekotsi_23_HDRI.hdr?url'
export const useVRM = defineStore('vrm', () => {
const indexedDbModelFile = ref<File | null>(null)
@@ -82,7 +84,12 @@ export const useVRM = defineStore('vrm', () => {
const trackingMode = useLocalStorage('settings/vrm/trackingMode', 'none' as 'camera' | 'mouse' | 'none')
const eyeHeight = useLocalStorage('settings/vrm/eyeHeight', 0)
// environment related setting
const envSelect = useLocalStorage('settings/vrm/envEnabled', 'hemisphere' as 'hemisphere' | 'skyBox')
const skyBoxSrc = useLocalStorage('settings/vrm/skyBoxUrl', defaultSkyBoxSrc)
return {
defaultModelUrl,
modelFile,
modelUrl,
modelSize,
@@ -114,5 +121,9 @@ export const useVRM = defineStore('vrm', () => {
isTracking,
trackingMode,
eyeHeight,
envSelect,
skyBoxSrc,
}
})