feat(stage-ui): Switch between hemisphere light and sky box (#357)
This commit is contained in:
@@ -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()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user