Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions .storybook/stories/HTML.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ import { Meta, StoryObj } from '@storybook/react-vite'
export default {
title: 'Misc/Html',
component: Html,
args: {
pixelPerfect: false, // Round transforms to whole pixels for crisp rendering (default: false)
},
decorators: [
(Story) => (
<Setup cameraPosition={new THREE.Vector3(-20, 20, -20)}>
Expand Down Expand Up @@ -206,3 +209,86 @@ export const HTMLOccluderSt = {
render: (args) => <HTMLOccluderScene {...args} />,
name: 'Occlusion',
} satisfies Story

//

import { ScrollControls, Scroll } from '../../src'

function PixelPerfectScene() {
return (
<ScrollControls pages={3} damping={0.1}>
<Scroll>
<ambientLight intensity={0.4} />
<directionalLight position={[5, 5, 5]} />

{/* Pixel Perfect OFF */}
<Icosahedron args={[2, 2]} position={[-5, 2, 0]}>
<meshBasicMaterial color="hotpink" wireframe />
<Html pixelPerfect={false} transform className="html-story-block">
Pixel Perfect OFF
</Html>
</Icosahedron>

<Icosahedron args={[2, 2]} position={[-5, -2, 0]}>
<meshBasicMaterial color="hotpink" wireframe />
<Html pixelPerfect={false} transform className="html-story-block">
Blurry when scrolling
</Html>
</Icosahedron>

{/* Pixel Perfect ON */}
<Icosahedron args={[2, 2]} position={[5, 2, 0]}>
<meshBasicMaterial color="palegreen" wireframe />
<Html pixelPerfect={true} transform className="html-story-block">
Pixel Perfect ON
</Html>
</Icosahedron>

<Icosahedron args={[2, 2]} position={[5, -2, 0]}>
<meshBasicMaterial color="palegreen" wireframe />
<Html pixelPerfect={true} transform className="html-story-block">
Crisp when scrolling
</Html>
</Icosahedron>
</Scroll>
<Scroll html>
<div
style={{
position: 'absolute',
top: '20px',
left: '20px',
backgroundColor: 'rgba(0, 0, 0, 0.8)',
color: 'white',
padding: '12px',
borderRadius: '8px',
fontSize: '16px',
fontFamily: 'monospace',
}}
>
<div>Left: Pixel Perfect OFF (hotpink)</div>
<div>Right: Pixel Perfect ON (green)</div>
<div style={{ marginTop: '8px', fontSize: '12px', opacity: 0.7 }}>
Scroll to see the difference in text crispness
</div>
</div>
</Scroll>
</ScrollControls>
)
}

export const PixelPerfectSt = {
decorators: [
(Story) => (
<Setup
controls={false}
cameraPosition={new THREE.Vector3(0, 0, 10)}
gl={{ alpha: false, antialias: false, stencil: false, depth: false }}
dpr={[1, 1.5]}
>
<Story />
</Setup>
),
],
render: () => <PixelPerfectScene />,
name: 'Pixel Perfect Comparison',
} satisfies Story
3 changes: 2 additions & 1 deletion .storybook/stories/ScrollControls.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export default {
damping: 4, // Friction, higher is faster (default: 4)
horizontal: false, // Can also scroll horizontally (default: false)
infinite: false, // Can also scroll infinitely (default: false)
pixelPerfect: true, // Round transforms to whole pixels for crisp rendering (default: false)
},
} satisfies Meta<typeof ScrollControls>

Expand Down Expand Up @@ -65,7 +66,7 @@ const ScrollControlsScene = (props: React.ComponentProps<typeof ScrollControls>)
<Suzanne position={[-viewport.width / 8, -viewport.height * 1, 0]} scale={[3, 3, 3]} />
<Suzanne position={[viewport.width / 4, -viewport.height * 2, 0]} scale={[1.5, 1.5, 1.5]} />
</Scroll>
<Scroll html style={{ width: '100%', color: '#EC2D2D' }}>
<Scroll html pixelPerfect style={{ width: '100%', color: '#EC2D2D' }}>
{/*
If the canvas is 100% of viewport then:
top: `${canvasSize.height * 1.0}px`
Expand Down
20 changes: 19 additions & 1 deletion docs/controls/scroll-controls.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,29 @@ type ScrollControlsProps = {
style?: React.CSSProperties
children: React.ReactNode
}

type ScrollProps = {
/** Rounds transform values to whole pixels to prevent subpixel rendering blur, default false */
pixelPerfect?: boolean
/** All other props passed to the scroll container */
[key: string]: any
}
```

Scroll controls create an HTML scroll container in front of the canvas. Everything you drop into the `<Scroll>` component will be affected.

You can listen and react to scroll with the `useScroll` hook which gives you useful data like the current scroll `offset`, `delta` and functions for range finding: `range`, `curve` and `visible`. The latter functions are especially useful if you want to react to the scroll offset, for instance if you wanted to fade things in and out if they are in or out of view.

## PixelPerfect Scrolling

When using `<Scroll html>` components, you may notice subpixel rendering blur caused by CSS `transform3d` values with decimal pixels. To fix this, use the `pixelPerfect` prop which rounds transform values to whole pixels:

```jsx
<Scroll html pixelPerfect>
{/* Content will scroll with crisp, pixel-perfect rendering */}
</Scroll>
```

```jsx
;<ScrollControls pages={3} damping={0.1}>
{/* Canvas contents in here will *not* scroll, but receive useScroll! */}
Expand All @@ -67,8 +84,9 @@ You can listen and react to scroll with the `useScroll` hook which gives you use
<Foo position={[0, viewport.height, 0]} />
<Foo position={[0, viewport.height * 1, 0]} />
</Scroll>
<Scroll html>
<Scroll html pixelPerfect>
{/* DOM contents in here will scroll along */}
{/* pixelPerfect prevents subpixel rendering blur by rounding transform values */}
<h1>html in here (optional)</h1>
<h1 style={{ top: '100vh' }}>second page</h1>
<h1 style={{ top: '200vh' }}>third page</h1>
Expand Down
11 changes: 10 additions & 1 deletion docs/misc/html.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ Allows you to tie HTML content to any object of your scene. It will be projected
portal={domnodeRef} // Reference to target container (default=undefined)
transform // If true, applies matrix3d transformations (default=false)
sprite // Renders as sprite, but only in transform mode (default=false)
pixelPerfect // Rounds transform values to whole pixels for crisp rendering (default=false)
calculatePosition={(el: Object3D, camera: Camera, size: { width: number; height: number }) => number[]} // Override default positioning function. (default=undefined) [ignored in transform mode]
occlude={[ref]} // Can be true or a Ref<Object3D>[], true occludes the entire scene (default: undefined)
onOcclude={(hidden) => null} // Callback when the visibility changes (default: undefined)
Expand Down Expand Up @@ -111,7 +112,15 @@ Enable shadows using the `castShadow` and `recieveShadow` prop.

If transform mode is enabled, the dimensions of the rendered html will depend on the position relative to the camera, the camera fov and the distanceFactor. For example, an Html component placed at (0,0,0) and with a distanceFactor of 10, rendered inside a scene with a perspective camera positioned at (0,0,2.45) and a FOV of 75, will have the same dimensions as a "plain" html element like in [this example](https://codesandbox.io/s/drei-html-magic-number-6mzt6m).

A caveat of transform mode is that on some devices and browsers, the rendered html may appear blurry, as discussed in [#859](https://github.com/pmndrs/drei/issues/859). The issue can be at least mitigated by scaling down the Html parent and scaling up the html children:
A caveat of transform mode is that on some devices and browsers, the rendered html may appear blurry, as discussed in [#859](https://github.com/pmndrs/drei/issues/859). This can be solved using the `pixelPerfect` prop which rounds transform values to whole pixels, ensuring crisp text rendering:

```jsx
<Html transform pixelPerfect>
<div>Crisp text that won't blur during animations</div>
</Html>
```

Alternatively, the issue can be mitigated by scaling down the Html parent and scaling up the html children:

```jsx
<Html transform scale={0.5}>
Expand Down
4 changes: 2 additions & 2 deletions src/core/Splat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,7 @@ async function load(shared: SharedState) {
shared.centerAndScaleData = new Float32Array(shared.bufferTextureWidth * shared.bufferTextureHeight * 4)
shared.covAndColorData = new Uint32Array(shared.bufferTextureWidth * shared.bufferTextureHeight * 4)
shared.centerAndScaleTexture = new THREE.DataTexture(
shared.centerAndScaleData,
shared.centerAndScaleData as BufferSource,
shared.bufferTextureWidth,
shared.bufferTextureHeight,
THREE.RGBAFormat,
Expand All @@ -322,7 +322,7 @@ async function load(shared: SharedState) {

shared.centerAndScaleTexture.needsUpdate = true
shared.covAndColorTexture = new THREE.DataTexture(
shared.covAndColorData,
shared.covAndColorData as BufferSource,
shared.bufferTextureWidth,
shared.bufferTextureHeight,
THREE.RGBAIntegerFormat,
Expand Down
39 changes: 36 additions & 3 deletions src/web/Html.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,13 @@ function objectZIndex(el: Object3D, camera: Camera, zIndexRange: Array<number>)

const epsilon = (value: number) => (Math.abs(value) < 1e-10 ? 0 : value)

// Helper function for pixel-perfect rounding based on device pixel ratio
// Credit: https://github.com/chris-xinhai-li (pmndrs/drei#2380)
function roundToPixelRatio(value: number): number {
const ratio = window.devicePixelRatio || 1
return Math.round(value * ratio) / ratio
}

function getCSSMatrix(matrix: Matrix4, multipliers: number[], prepend = '') {
let matrix3d = 'matrix3d('
for (let i = 0; i !== 16; i++) {
Expand Down Expand Up @@ -126,6 +133,7 @@ export interface HtmlProps extends Omit<Assign<React.HTMLAttributes<HTMLDivEleme
distanceFactor?: number
sprite?: boolean
transform?: boolean
pixelPerfect?: boolean
zIndexRange?: Array<number>
calculatePosition?: CalculatePosition
as?: string
Expand Down Expand Up @@ -157,6 +165,7 @@ export const Html: ForwardRefComponent<HtmlProps, HTMLDivElement> = /* @__PURE__
distanceFactor,
sprite = false,
transform = false,
pixelPerfect = false,
occlude,
onOcclude,
castShadow,
Expand All @@ -181,6 +190,9 @@ export const Html: ForwardRefComponent<HtmlProps, HTMLDivElement> = /* @__PURE__
const oldPosition = React.useRef([0, 0])
const transformOuterRef = React.useRef<HTMLDivElement>(null!)
const transformInnerRef = React.useRef<HTMLDivElement>(null!)

// Track velocity for pixelPerfect optimization
const prevPosition = React.useRef([0, 0])
// Append to the connected element, which makes HTML work with views
const target = (portal?.current || events.connected || gl.domElement.parentNode) as HTMLElement

Expand Down Expand Up @@ -215,7 +227,10 @@ export const Html: ForwardRefComponent<HtmlProps, HTMLDivElement> = /* @__PURE__
el.style.cssText = `position:absolute;top:0;left:0;pointer-events:none;overflow:hidden;`
} else {
const vec = calculatePosition(group.current, camera, size)
el.style.cssText = `position:absolute;top:0;left:0;transform:translate3d(${vec[0]}px,${vec[1]}px,0);transform-origin:0 0;`
// Apply pixelPerfect rounding to initial position if enabled
const finalX = pixelPerfect ? roundToPixelRatio(vec[0]) : vec[0]
const finalY = pixelPerfect ? roundToPixelRatio(vec[1]) : vec[1]
el.style.cssText = `position:absolute;top:0;left:0;transform:translate3d(${finalX}px,${finalY}px,0);transform-origin:0 0;`
}
if (target) {
if (prepend) target.prepend(el)
Expand Down Expand Up @@ -287,6 +302,13 @@ export const Html: ForwardRefComponent<HtmlProps, HTMLDivElement> = /* @__PURE__
group.current.updateWorldMatrix(true, false)
const vec = transform ? oldPosition.current : calculatePosition(group.current, camera, size)

// Calculate velocity for pixelPerfect optimization
const velocity = pixelPerfect ? Math.sqrt(
Math.pow(vec[0] - prevPosition.current[0], 2) +
Math.pow(vec[1] - prevPosition.current[1], 2)
) : 0
prevPosition.current = [vec[0], vec[1]]

if (
transform ||
Math.abs(oldZoom.current - camera.zoom) > eps ||
Expand Down Expand Up @@ -344,12 +366,23 @@ export const Html: ForwardRefComponent<HtmlProps, HTMLDivElement> = /* @__PURE__
el.style.height = size.height + 'px'
el.style.perspective = isOrthographicCamera ? '' : `${fov}px`
if (transformOuterRef.current && transformInnerRef.current) {
transformOuterRef.current.style.transform = `${cameraTransform}${cameraMatrix}translate(${widthHalf}px,${heightHalf}px)`
// Apply pixelPerfect rounding to camera transform when velocity is very low
const shouldApplyPixelPerfect = pixelPerfect && velocity < 0.001
const finalWidthHalf = shouldApplyPixelPerfect ? roundToPixelRatio(widthHalf) : widthHalf
const finalHeightHalf = shouldApplyPixelPerfect ? roundToPixelRatio(heightHalf) : heightHalf

transformOuterRef.current.style.transform = `${cameraTransform}${cameraMatrix}translate(${finalWidthHalf}px,${finalHeightHalf}px)`
transformInnerRef.current.style.transform = getObjectCSSMatrix(matrix, 1 / ((distanceFactor || 10) / 400))
}
} else {
const scale = distanceFactor === undefined ? 1 : objectScale(group.current, camera) * distanceFactor
el.style.transform = `translate3d(${vec[0]}px,${vec[1]}px,0) scale(${scale})`

// Apply pixelPerfect rounding when velocity is very low (nearly stopped)
const shouldApplyPixelPerfect = pixelPerfect && velocity < 0.001
const finalX = shouldApplyPixelPerfect ? roundToPixelRatio(vec[0]) : vec[0]
const finalY = shouldApplyPixelPerfect ? roundToPixelRatio(vec[1]) : vec[1]

el.style.transform = `translate3d(${finalX}px,${finalY}px,0) scale(${scale})`
}
oldPosition.current = vec
oldZoom.current = camera.zoom
Expand Down
88 changes: 58 additions & 30 deletions src/web/ScrollControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@ export function useScroll() {
return React.useContext(context)
}

// Helper function for pixel-perfect rounding based on device pixel ratio
// Credit: https://github.com/chris-xinhai-li (pmndrs/drei#2380)
function roundToPixelRatio(value: number): number {
const ratio = window.devicePixelRatio || 1
return Math.round(value * ratio) / ratio
}

export function ScrollControls({
eps = 0.00001,
enabled = true,
Expand Down Expand Up @@ -228,36 +235,56 @@ const ScrollCanvas = /* @__PURE__ */ React.forwardRef(
}
)

const ScrollHtml: ForwardRefComponent<{ children?: React.ReactNode; style?: React.CSSProperties }, HTMLDivElement> =
React.forwardRef(
({ children, style, ...props }: { children?: React.ReactNode; style?: React.CSSProperties }, ref) => {
const state = useScroll()
const group = React.useRef<HTMLDivElement>(null!)
React.useImperativeHandle(ref, () => group.current, [])
const { width, height } = useThree((state) => state.size)
const fiberState = React.useContext(fiberContext)
const root = React.useMemo(() => ReactDOM.createRoot(state.fixed), [state.fixed])
useFrame(() => {
if (state.delta > state.eps) {
group.current.style.transform = `translate3d(${
state.horizontal ? -width * (state.pages - 1) * state.offset : 0
}px,${state.horizontal ? 0 : height * (state.pages - 1) * -state.offset}px,0)`
}
})
root.render(
<div
ref={group}
style={{ ...style, position: 'absolute', top: 0, left: 0, willChange: 'transform' }}
{...props}
>
<context.Provider value={state}>
<fiberContext.Provider value={fiberState}>{children}</fiberContext.Provider>
</context.Provider>
</div>
)
return null
}
)
const ScrollHtml: ForwardRefComponent<
{ children?: React.ReactNode; style?: React.CSSProperties; pixelPerfect?: boolean },
HTMLDivElement
> = React.forwardRef(
(
{
children,
style,
pixelPerfect,
...props
}: { children?: React.ReactNode; style?: React.CSSProperties; pixelPerfect?: boolean },
ref
) => {
const state = useScroll()
const group = React.useRef<HTMLDivElement>(null!)
React.useImperativeHandle(ref, () => group.current, [])
const { width, height } = useThree((state) => state.size)
const fiberState = React.useContext(fiberContext)
const root = React.useMemo(() => ReactDOM.createRoot(state.fixed), [state.fixed])

// Track velocity for pixelPerfect optimization
const prevOffset = React.useRef(state.offset)

useFrame(() => {
if (state.delta > state.eps) {
const x = state.horizontal ? -width * (state.pages - 1) * state.offset : 0
const y = state.horizontal ? 0 : height * (state.pages - 1) * -state.offset

// Calculate velocity by comparing current offset to previous frame
const velocity = Math.abs(state.offset - prevOffset.current)
prevOffset.current = state.offset

// Apply pixelPerfect rounding when velocity is very low (nearly stopped)
const shouldApplyPixelPerfect = pixelPerfect && velocity < 0.001
const finalX = shouldApplyPixelPerfect ? roundToPixelRatio(x) : x
const finalY = shouldApplyPixelPerfect ? roundToPixelRatio(y) : y

group.current.style.transform = `translate3d(${finalX}px,${finalY}px,0)`
}
})
root.render(
<div ref={group} style={{ ...style, position: 'absolute', top: 0, left: 0, willChange: 'transform' }} {...props}>
<context.Provider value={state}>
<fiberContext.Provider value={fiberState}>{children}</fiberContext.Provider>
</context.Provider>
</div>
)
return null
}
)

interface ScrollPropsWithFalseHtml {
children?: React.ReactNode
Expand All @@ -269,6 +296,7 @@ interface ScrollPropsWithTrueHtml {
children?: React.ReactNode
html: true
style?: React.CSSProperties
pixelPerfect?: boolean
}

export type ScrollProps = ScrollPropsWithFalseHtml | ScrollPropsWithTrueHtml
Expand Down
Loading