UNPKG

@pmndrs/uikit

Version:

Build performant 3D user interfaces with Three.js and yoga.

269 lines (268 loc) 14.5 kB
import { computed, effect, signal } from '@preact/signals-core'; import { Mesh, MeshBasicMaterial, Sphere, SRGBColorSpace, Texture, TextureLoader, } from 'three'; import { createFlexNodeState } from '../flex/index.js'; import { ElementType, computedOrderInfo, setupRenderOrder } from '../order.js'; import { PanelDepthMaterial, PanelDistanceMaterial, createPanelMaterial, createPanelMaterialConfig, panelGeometry, computedPanelGroupDependencies, } from '../panel/index.js'; import { createScrollPosition, setupScrollbars, computedScrollHandlers, computedAnyAncestorScrollable, createScrollState, setupScroll, computedGlobalScrollMatrix, } from '../scroll.js'; import { setupObjectTransform, computedTransformMatrix } from '../transform.js'; import { computeDefaultProperties, computedGlobalMatrix, computedHandlers, computedIsVisible, computedMergedProperties, setupNode, keepAspectRatioPropertyTransformer, loadResourceWithParams, setupMatrixWorldUpdate, setupPointerEvents, computedAncestorsHaveListeners, } from './utils.js'; import { abortableEffect, readReactive } from '../utils.js'; import { setupImmediateProperties } from '../properties/immediate.js'; import { setupBoundingSphere, makeClippedCast, makePanelRaycast, makePanelSpherecast, } from '../panel/interaction-panel-mesh.js'; import { computedClippingRect, computedIsClipped, createGlobalClippingPlanes } from '../clipping.js'; import { setupLayoutListeners, setupClippedListeners } from '../listeners.js'; import { computedInheritableProperty } from '../properties/utils.js'; import { createActivePropertyTransfomers } from '../active.js'; import { createHoverPropertyTransformers, setupCursorCleanup } from '../hover.js'; import { createResponsivePropertyTransformers } from '../responsive.js'; import { darkPropertyTransformers } from '../dark.js'; const defaultImageFit = 'fill'; export function createImageState(parentCtx, objectRef, style, properties, defaultProperties) { const flexState = createFlexNodeState(); const texture = signal(undefined); const hoveredSignal = signal([]); const activeSignal = signal([]); const src = computed(() => readReactive(style.value?.src) ?? readReactive(properties.value?.src)); const textureAspectRatio = computed(() => { const tex = texture.value; if (tex == null) { return undefined; } const image = tex.source.data; return image.width / image.height; }); const mergedProperties = computedMergedProperties(style, properties, defaultProperties, { ...darkPropertyTransformers, ...createResponsivePropertyTransformers(parentCtx.root.size), ...createHoverPropertyTransformers(hoveredSignal), ...createActivePropertyTransfomers(activeSignal), }, keepAspectRatioPropertyTransformer, (m) => m.add('aspectRatio', textureAspectRatio)); const transformMatrix = computedTransformMatrix(mergedProperties, flexState, parentCtx.root.pixelSize); const globalMatrix = computedGlobalMatrix(parentCtx.childrenMatrix, transformMatrix); const isClipped = computedIsClipped(parentCtx.clippingRect, globalMatrix, flexState.size, parentCtx.root.pixelSize); const isHidden = computed(() => isClipped.value || texture.value == null); const isVisible = computedIsVisible(flexState, isHidden, mergedProperties); const orderInfo = computedOrderInfo(mergedProperties, 'zIndexOffset', ElementType.Image, undefined, parentCtx.orderInfo); const scrollPosition = createScrollPosition(); const childrenMatrix = computedGlobalScrollMatrix(scrollPosition, globalMatrix, parentCtx.root.pixelSize); const scrollbarWidth = computedInheritableProperty(mergedProperties, 'scrollbarWidth', 10); const componentState = Object.assign(flexState, { texture, hoveredSignal, activeSignal, src, mergedProperties, transformMatrix, globalMatrix, isClipped, isHidden, isVisible, orderInfo, groupDeps: computedPanelGroupDependencies(mergedProperties), scrollPosition, scrollbarWidth, childrenMatrix, scrollState: createScrollState(), defaultProperties: computeDefaultProperties(mergedProperties), anyAncestorScrollable: computedAnyAncestorScrollable(flexState.scrollable, parentCtx.anyAncestorScrollable), root: parentCtx.root, }); const scrollHandlers = computedScrollHandlers(componentState, properties, objectRef); const handlers = computedHandlers(style, properties, defaultProperties, hoveredSignal, activeSignal, scrollHandlers); const ancestorsHaveListeners = computedAncestorsHaveListeners(parentCtx, handlers); return Object.assign(componentState, { handlers, ancestorsHaveListeners, interactionPanel: createImageMesh(componentState, globalMatrix, parentCtx, orderInfo, parentCtx.root), clippingRect: computedClippingRect(globalMatrix, componentState, parentCtx.root.pixelSize, parentCtx.clippingRect), }); } export function setupImage(state, parentCtx, style, properties, object, childrenContainer, abortSignal) { setupCursorCleanup(state.hoveredSignal, abortSignal); loadResourceWithParams(state.texture, loadTextureImpl, cleanupTexture, abortSignal, state.src); setupNode(state, parentCtx, object, true, abortSignal); setupObjectTransform(parentCtx.root, object, state.transformMatrix, abortSignal); setupScroll(state, properties, parentCtx.root.pixelSize, childrenContainer, abortSignal); setupScrollbars(state.mergedProperties, state.scrollPosition, state, state.globalMatrix, state.isVisible, parentCtx.clippingRect, state.orderInfo, state.groupDeps, parentCtx.root.panelGroupManager, state.scrollbarWidth, abortSignal); setupPointerEvents(state.mergedProperties, state.ancestorsHaveListeners, parentCtx.root, state.interactionPanel, false, abortSignal); const updateMatrixWorld = computedInheritableProperty(state.mergedProperties, 'updateMatrixWorld', false); setupMatrixWorldUpdate(updateMatrixWorld, false, object, parentCtx.root, state.globalMatrix, false, abortSignal); setupMatrixWorldUpdate(true, false, state.interactionPanel, parentCtx.root, state.globalMatrix, true, abortSignal); setupLayoutListeners(style, properties, state.size, abortSignal); setupClippedListeners(style, properties, state.isClipped, abortSignal); setupImageMesh(state.interactionPanel, state.mergedProperties, state.texture, state.globalMatrix, parentCtx, state, state.root, state.isVisible, abortSignal); } let imageMaterialConfig; function getImageMaterialConfig() { imageMaterialConfig ??= createPanelMaterialConfig({ borderBend: 'borderBend', borderBottomLeftRadius: 'borderBottomLeftRadius', borderBottomRightRadius: 'borderBottomRightRadius', borderColor: 'borderColor', borderOpacity: 'borderOpacity', borderTopLeftRadius: 'borderTopLeftRadius', borderTopRightRadius: 'borderTopRightRadius', backgroundOpacity: 'opacity', }, { backgroundColor: 0xffffff, }); return imageMaterialConfig; } function createImageMesh(flexState, globalMatrix, parentContext, orderInfo, root) { const mesh = Object.assign(new Mesh(panelGeometry), { boundingSphere: new Sphere(), }); mesh.frustumCulled = false; mesh.matrixAutoUpdate = false; mesh.raycast = makeClippedCast(mesh, makePanelRaycast(mesh.raycast.bind(mesh), root.objectRef, mesh.boundingSphere, globalMatrix, mesh), root.objectRef, parentContext.clippingRect, orderInfo, flexState); mesh.spherecast = makeClippedCast(mesh, makePanelSpherecast(root.objectRef, mesh.boundingSphere, globalMatrix, mesh), root.objectRef, parentContext.clippingRect, orderInfo, flexState); setupRenderOrder(mesh, root, orderInfo); return mesh; } function setupImageMesh(mesh, propertiesSignal, textureSignal, globalMatrix, parentContext, flexState, root, isVisible, abortSignal) { const clippingPlanes = createGlobalClippingPlanes(root, parentContext.clippingRect); const isMeshVisible = getImageMaterialConfig().computedIsVisibile(propertiesSignal, flexState.borderInset, flexState.size, isVisible); setupImageMaterials(propertiesSignal, textureSignal, mesh, flexState.size, flexState.borderInset, isMeshVisible, clippingPlanes, root, abortSignal); setupBoundingSphere(mesh.boundingSphere, parentContext.root.pixelSize, globalMatrix, flexState.size, abortSignal); const objectFit = computedInheritableProperty(propertiesSignal, 'objectFit', defaultImageFit); abortableEffect(() => { const texture = textureSignal.value; if (texture == null || flexState.size.value == null || flexState.borderInset.value == null) { return; } texture.matrix.identity(); root.requestRender(); if (objectFit.value === 'fill' || texture == null) { transformInsideBorder(flexState.borderInset, flexState.size, texture); return; } const { width: textureWidth, height: textureHeight } = texture.source.data; const textureRatio = textureWidth / textureHeight; const [width, height] = flexState.size.value; const [top, right, bottom, left] = flexState.borderInset.value; const boundsRatioValue = (width - left - right) / (height - top - bottom); if (textureRatio > boundsRatioValue) { texture.matrix .translate(-(0.5 * (boundsRatioValue - textureRatio)) / boundsRatioValue, 0) .scale(boundsRatioValue / textureRatio, 1); } else { texture.matrix .translate(0, -(0.5 * (textureRatio - boundsRatioValue)) / textureRatio) .scale(1, textureRatio / boundsRatioValue); } transformInsideBorder(flexState.borderInset, flexState.size, texture); }, abortSignal); abortableEffect(() => { mesh.visible = isMeshVisible.value; parentContext.root.requestRender(); }, abortSignal); abortableEffect(() => { if (flexState.size.value == null) { return; } const [width, height] = flexState.size.value; const pixelSize = parentContext.root.pixelSize.value; mesh.scale.set(width * pixelSize, height * pixelSize, 1); mesh.updateMatrix(); parentContext.root.requestRender(); }, abortSignal); } function transformInsideBorder(borderInset, size, texture) { if (size.value == null || borderInset.value == null) { return; } const [outerWidth, outerHeight] = size.value; const [top, right, bottom, left] = borderInset.value; const width = outerWidth - left - right; const height = outerHeight - top - bottom; texture.matrix .translate(-1 + (left + width) / outerWidth, -1 + (top + height) / outerHeight) .scale(outerWidth / width, outerHeight / height); } const textureLoader = new TextureLoader(); function cleanupTexture(texture) { if (texture?.disposable === true) { texture.dispose(); } } async function loadTextureImpl(src) { if (src == null) { return Promise.resolve(undefined); } if (src instanceof Texture) { return Promise.resolve(src); } try { const texture = await textureLoader.loadAsync(src); texture.colorSpace = SRGBColorSpace; texture.matrixAutoUpdate = false; return Object.assign(texture, { disposable: true }); } catch (error) { console.error(error); return undefined; } } function setupImageMaterials(propertiesSignal, textureSignal, target, size, borderInset, isVisible, clippingPlanes, root, abortSignal) { const data = new Float32Array(16); const info = { data: data, type: 'normal' }; target.customDepthMaterial = new PanelDepthMaterial(info); target.customDistanceMaterial = new PanelDistanceMaterial(info); target.customDepthMaterial.clippingPlanes = clippingPlanes; target.customDistanceMaterial.clippingPlanes = clippingPlanes; abortableEffect(() => { const material = createPanelMaterial(propertiesSignal.value.read('panelMaterialClass', MeshBasicMaterial), info); material.clippingPlanes = clippingPlanes; target.material = material; const cleanupDepthTestEffect = effect(() => { material.depthTest = propertiesSignal.value.read('depthTest', true); root.requestRender(); }); const cleanupDepthWriteEffect = effect(() => { material.depthWrite = propertiesSignal.value.read('depthWrite', false); root.requestRender(); }); const cleanupTextureEffect = effect(() => { ; material.map = textureSignal.value ?? null; material.needsUpdate = true; root.requestRender(); }); return () => { cleanupTextureEffect(); cleanupDepthWriteEffect(); cleanupDepthTestEffect(); material.dispose(); }; }, abortSignal); abortableEffect(() => { target.renderOrder = propertiesSignal.value.read('renderOrder', 0); root.requestRender(); }, abortSignal); abortableEffect(() => { target.castShadow = propertiesSignal.value.read('castShadow', false); root.requestRender(); }, abortSignal); abortableEffect(() => { target.receiveShadow = propertiesSignal.value.read('receiveShadow', false); root.requestRender(); }, abortSignal); const imageMaterialConfig = getImageMaterialConfig(); abortableEffect(() => { if (!isVisible.value) { return; } const innerAbortController = new AbortController(); data.set(imageMaterialConfig.defaultData); abortableEffect(() => void (size.value != null && data.set(size.value, 13)), innerAbortController.signal); abortableEffect(() => void (borderInset.value != null && data.set(borderInset.value, 0)), innerAbortController.signal); root.requestRender(); return () => innerAbortController.abort(); }, abortSignal); const setters = imageMaterialConfig.setters; setupImmediateProperties(propertiesSignal, isVisible, imageMaterialConfig.hasProperty, (key, value) => { setters[key](data, 0, value, size, undefined); root.requestRender(); }, abortSignal); }