Skip to content

Commit 9f0fc53

Browse files
artokunclaude
andcommitted
feat: add pixelPerfect prop to ScrollControls and Html components
Adds pixelPerfect prop to prevent subpixel rendering blur caused by CSS transform3d values with decimal pixels. Uses devicePixelRatio-based rounding for accurate rendering across all display types. Features: - ScrollControls: pixelPerfect prop for Scroll html components - Html: pixelPerfect prop for transform mode - Velocity-based optimization (only applies when motion < 0.001) - devicePixelRatio support for high-DPI displays - Comprehensive Storybook examples and documentation - Performance optimized with conditional application Credit: chris-xinhai-li for devicePixelRatio suggestion (#2380) Fixes: #859, #2380 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent 3b5d7dc commit 9f0fc53

File tree

8 files changed

+218
-42
lines changed

8 files changed

+218
-42
lines changed

.storybook/stories/HTML.stories.tsx

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ import { Meta, StoryObj } from '@storybook/react-vite'
1212
export default {
1313
title: 'Misc/Html',
1414
component: Html,
15+
args: {
16+
pixelPerfect: false, // Round transforms to whole pixels for crisp rendering (default: false)
17+
},
1518
decorators: [
1619
(Story) => (
1720
<Setup cameraPosition={new THREE.Vector3(-20, 20, -20)}>
@@ -206,3 +209,86 @@ export const HTMLOccluderSt = {
206209
render: (args) => <HTMLOccluderScene {...args} />,
207210
name: 'Occlusion',
208211
} satisfies Story
212+
213+
//
214+
215+
import { ScrollControls, Scroll } from '../../src'
216+
217+
function PixelPerfectScene() {
218+
return (
219+
<ScrollControls pages={3} damping={0.1}>
220+
<Scroll>
221+
<ambientLight intensity={0.4} />
222+
<directionalLight position={[5, 5, 5]} />
223+
224+
{/* Pixel Perfect OFF */}
225+
<Icosahedron args={[2, 2]} position={[-5, 2, 0]}>
226+
<meshBasicMaterial color="hotpink" wireframe />
227+
<Html pixelPerfect={false} transform className="html-story-block">
228+
Pixel Perfect OFF
229+
</Html>
230+
</Icosahedron>
231+
232+
<Icosahedron args={[2, 2]} position={[-5, -2, 0]}>
233+
<meshBasicMaterial color="hotpink" wireframe />
234+
<Html pixelPerfect={false} transform className="html-story-block">
235+
Blurry when scrolling
236+
</Html>
237+
</Icosahedron>
238+
239+
{/* Pixel Perfect ON */}
240+
<Icosahedron args={[2, 2]} position={[5, 2, 0]}>
241+
<meshBasicMaterial color="palegreen" wireframe />
242+
<Html pixelPerfect={true} transform className="html-story-block">
243+
Pixel Perfect ON
244+
</Html>
245+
</Icosahedron>
246+
247+
<Icosahedron args={[2, 2]} position={[5, -2, 0]}>
248+
<meshBasicMaterial color="palegreen" wireframe />
249+
<Html pixelPerfect={true} transform className="html-story-block">
250+
Crisp when scrolling
251+
</Html>
252+
</Icosahedron>
253+
</Scroll>
254+
<Scroll html>
255+
<div
256+
style={{
257+
position: 'absolute',
258+
top: '20px',
259+
left: '20px',
260+
backgroundColor: 'rgba(0, 0, 0, 0.8)',
261+
color: 'white',
262+
padding: '12px',
263+
borderRadius: '8px',
264+
fontSize: '16px',
265+
fontFamily: 'monospace',
266+
}}
267+
>
268+
<div>Left: Pixel Perfect OFF (hotpink)</div>
269+
<div>Right: Pixel Perfect ON (green)</div>
270+
<div style={{ marginTop: '8px', fontSize: '12px', opacity: 0.7 }}>
271+
Scroll to see the difference in text crispness
272+
</div>
273+
</div>
274+
</Scroll>
275+
</ScrollControls>
276+
)
277+
}
278+
279+
export const PixelPerfectSt = {
280+
decorators: [
281+
(Story) => (
282+
<Setup
283+
controls={false}
284+
cameraPosition={new THREE.Vector3(0, 0, 10)}
285+
gl={{ alpha: false, antialias: false, stencil: false, depth: false }}
286+
dpr={[1, 1.5]}
287+
>
288+
<Story />
289+
</Setup>
290+
),
291+
],
292+
render: () => <PixelPerfectScene />,
293+
name: 'Pixel Perfect Comparison',
294+
} satisfies Story

.storybook/stories/ScrollControls.stories.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export default {
1515
damping: 4, // Friction, higher is faster (default: 4)
1616
horizontal: false, // Can also scroll horizontally (default: false)
1717
infinite: false, // Can also scroll infinitely (default: false)
18+
pixelPerfect: true, // Round transforms to whole pixels for crisp rendering (default: false)
1819
},
1920
} satisfies Meta<typeof ScrollControls>
2021

@@ -65,7 +66,7 @@ const ScrollControlsScene = (props: React.ComponentProps<typeof ScrollControls>)
6566
<Suzanne position={[-viewport.width / 8, -viewport.height * 1, 0]} scale={[3, 3, 3]} />
6667
<Suzanne position={[viewport.width / 4, -viewport.height * 2, 0]} scale={[1.5, 1.5, 1.5]} />
6768
</Scroll>
68-
<Scroll html style={{ width: '100%', color: '#EC2D2D' }}>
69+
<Scroll html pixelPerfect style={{ width: '100%', color: '#EC2D2D' }}>
6970
{/*
7071
If the canvas is 100% of viewport then:
7172
top: `${canvasSize.height * 1.0}px`

docs/controls/scroll-controls.mdx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,12 +51,29 @@ type ScrollControlsProps = {
5151
style?: React.CSSProperties
5252
children: React.ReactNode
5353
}
54+
55+
type ScrollProps = {
56+
/** Rounds transform values to whole pixels to prevent subpixel rendering blur, default false */
57+
pixelPerfect?: boolean
58+
/** All other props passed to the scroll container */
59+
[key: string]: any
60+
}
5461
```
5562
5663
Scroll controls create an HTML scroll container in front of the canvas. Everything you drop into the `<Scroll>` component will be affected.
5764
5865
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.
5966
67+
## PixelPerfect Scrolling
68+
69+
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:
70+
71+
```jsx
72+
<Scroll html pixelPerfect>
73+
{/* Content will scroll with crisp, pixel-perfect rendering */}
74+
</Scroll>
75+
```
76+
6077
```jsx
6178
;<ScrollControls pages={3} damping={0.1}>
6279
{/* Canvas contents in here will *not* scroll, but receive useScroll! */}
@@ -67,8 +84,9 @@ You can listen and react to scroll with the `useScroll` hook which gives you use
6784
<Foo position={[0, viewport.height, 0]} />
6885
<Foo position={[0, viewport.height * 1, 0]} />
6986
</Scroll>
70-
<Scroll html>
87+
<Scroll html pixelPerfect>
7188
{/* DOM contents in here will scroll along */}
89+
{/* pixelPerfect prevents subpixel rendering blur by rounding transform values */}
7290
<h1>html in here (optional)</h1>
7391
<h1 style={{ top: '100vh' }}>second page</h1>
7492
<h1 style={{ top: '200vh' }}>third page</h1>

docs/misc/html.mdx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ Allows you to tie HTML content to any object of your scene. It will be projected
3737
portal={domnodeRef} // Reference to target container (default=undefined)
3838
transform // If true, applies matrix3d transformations (default=false)
3939
sprite // Renders as sprite, but only in transform mode (default=false)
40+
pixelPerfect // Rounds transform values to whole pixels for crisp rendering (default=false)
4041
calculatePosition={(el: Object3D, camera: Camera, size: { width: number; height: number }) => number[]} // Override default positioning function. (default=undefined) [ignored in transform mode]
4142
occlude={[ref]} // Can be true or a Ref<Object3D>[], true occludes the entire scene (default: undefined)
4243
onOcclude={(hidden) => null} // Callback when the visibility changes (default: undefined)
@@ -111,7 +112,15 @@ Enable shadows using the `castShadow` and `recieveShadow` prop.
111112
112113
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).
113114

114-
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:
115+
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:
116+
117+
```jsx
118+
<Html transform pixelPerfect>
119+
<div>Crisp text that won't blur during animations</div>
120+
</Html>
121+
```
122+
123+
Alternatively, the issue can be mitigated by scaling down the Html parent and scaling up the html children:
115124
116125
```jsx
117126
<Html transform scale={0.5}>

src/core/Splat.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -313,7 +313,7 @@ async function load(shared: SharedState) {
313313
shared.centerAndScaleData = new Float32Array(shared.bufferTextureWidth * shared.bufferTextureHeight * 4)
314314
shared.covAndColorData = new Uint32Array(shared.bufferTextureWidth * shared.bufferTextureHeight * 4)
315315
shared.centerAndScaleTexture = new THREE.DataTexture(
316-
shared.centerAndScaleData,
316+
shared.centerAndScaleData as BufferSource,
317317
shared.bufferTextureWidth,
318318
shared.bufferTextureHeight,
319319
THREE.RGBAFormat,
@@ -322,7 +322,7 @@ async function load(shared: SharedState) {
322322

323323
shared.centerAndScaleTexture.needsUpdate = true
324324
shared.covAndColorTexture = new THREE.DataTexture(
325-
shared.covAndColorData,
325+
shared.covAndColorData as BufferSource,
326326
shared.bufferTextureWidth,
327327
shared.bufferTextureHeight,
328328
THREE.RGBAIntegerFormat,

src/web/Html.tsx

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,13 @@ function objectZIndex(el: Object3D, camera: Camera, zIndexRange: Array<number>)
8484

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

87+
// Helper function for pixel-perfect rounding based on device pixel ratio
88+
// Credit: https://github.com/chris-xinhai-li (pmndrs/drei#2380)
89+
function roundToPixelRatio(value: number): number {
90+
const ratio = window.devicePixelRatio || 1
91+
return Math.round(value * ratio) / ratio
92+
}
93+
8794
function getCSSMatrix(matrix: Matrix4, multipliers: number[], prepend = '') {
8895
let matrix3d = 'matrix3d('
8996
for (let i = 0; i !== 16; i++) {
@@ -126,6 +133,7 @@ export interface HtmlProps extends Omit<Assign<React.HTMLAttributes<HTMLDivEleme
126133
distanceFactor?: number
127134
sprite?: boolean
128135
transform?: boolean
136+
pixelPerfect?: boolean
129137
zIndexRange?: Array<number>
130138
calculatePosition?: CalculatePosition
131139
as?: string
@@ -157,6 +165,7 @@ export const Html: ForwardRefComponent<HtmlProps, HTMLDivElement> = /* @__PURE__
157165
distanceFactor,
158166
sprite = false,
159167
transform = false,
168+
pixelPerfect = false,
160169
occlude,
161170
onOcclude,
162171
castShadow,
@@ -181,6 +190,9 @@ export const Html: ForwardRefComponent<HtmlProps, HTMLDivElement> = /* @__PURE__
181190
const oldPosition = React.useRef([0, 0])
182191
const transformOuterRef = React.useRef<HTMLDivElement>(null!)
183192
const transformInnerRef = React.useRef<HTMLDivElement>(null!)
193+
194+
// Track velocity for pixelPerfect optimization
195+
const prevPosition = React.useRef([0, 0])
184196
// Append to the connected element, which makes HTML work with views
185197
const target = (portal?.current || events.connected || gl.domElement.parentNode) as HTMLElement
186198

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

305+
// Calculate velocity for pixelPerfect optimization
306+
const velocity = pixelPerfect ? Math.sqrt(
307+
Math.pow(vec[0] - prevPosition.current[0], 2) +
308+
Math.pow(vec[1] - prevPosition.current[1], 2)
309+
) : 0
310+
prevPosition.current = [vec[0], vec[1]]
311+
290312
if (
291313
transform ||
292314
Math.abs(oldZoom.current - camera.zoom) > eps ||
@@ -344,12 +366,23 @@ export const Html: ForwardRefComponent<HtmlProps, HTMLDivElement> = /* @__PURE__
344366
el.style.height = size.height + 'px'
345367
el.style.perspective = isOrthographicCamera ? '' : `${fov}px`
346368
if (transformOuterRef.current && transformInnerRef.current) {
347-
transformOuterRef.current.style.transform = `${cameraTransform}${cameraMatrix}translate(${widthHalf}px,${heightHalf}px)`
369+
// Apply pixelPerfect rounding to camera transform when velocity is very low
370+
const shouldApplyPixelPerfect = pixelPerfect && velocity < 0.001
371+
const finalWidthHalf = shouldApplyPixelPerfect ? roundToPixelRatio(widthHalf) : widthHalf
372+
const finalHeightHalf = shouldApplyPixelPerfect ? roundToPixelRatio(heightHalf) : heightHalf
373+
374+
transformOuterRef.current.style.transform = `${cameraTransform}${cameraMatrix}translate(${finalWidthHalf}px,${finalHeightHalf}px)`
348375
transformInnerRef.current.style.transform = getObjectCSSMatrix(matrix, 1 / ((distanceFactor || 10) / 400))
349376
}
350377
} else {
351378
const scale = distanceFactor === undefined ? 1 : objectScale(group.current, camera) * distanceFactor
352-
el.style.transform = `translate3d(${vec[0]}px,${vec[1]}px,0) scale(${scale})`
379+
380+
// Apply pixelPerfect rounding when velocity is very low (nearly stopped)
381+
const shouldApplyPixelPerfect = pixelPerfect && velocity < 0.001
382+
const finalX = shouldApplyPixelPerfect ? roundToPixelRatio(vec[0]) : vec[0]
383+
const finalY = shouldApplyPixelPerfect ? roundToPixelRatio(vec[1]) : vec[1]
384+
385+
el.style.transform = `translate3d(${finalX}px,${finalY}px,0) scale(${scale})`
353386
}
354387
oldPosition.current = vec
355388
oldZoom.current = camera.zoom

src/web/ScrollControls.tsx

Lines changed: 58 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,13 @@ export function useScroll() {
5252
return React.useContext(context)
5353
}
5454

55+
// Helper function for pixel-perfect rounding based on device pixel ratio
56+
// Credit: https://github.com/chris-xinhai-li (pmndrs/drei#2380)
57+
function roundToPixelRatio(value: number): number {
58+
const ratio = window.devicePixelRatio || 1
59+
return Math.round(value * ratio) / ratio
60+
}
61+
5562
export function ScrollControls({
5663
eps = 0.00001,
5764
enabled = true,
@@ -228,36 +235,56 @@ const ScrollCanvas = /* @__PURE__ */ React.forwardRef(
228235
}
229236
)
230237

231-
const ScrollHtml: ForwardRefComponent<{ children?: React.ReactNode; style?: React.CSSProperties }, HTMLDivElement> =
232-
React.forwardRef(
233-
({ children, style, ...props }: { children?: React.ReactNode; style?: React.CSSProperties }, ref) => {
234-
const state = useScroll()
235-
const group = React.useRef<HTMLDivElement>(null!)
236-
React.useImperativeHandle(ref, () => group.current, [])
237-
const { width, height } = useThree((state) => state.size)
238-
const fiberState = React.useContext(fiberContext)
239-
const root = React.useMemo(() => ReactDOM.createRoot(state.fixed), [state.fixed])
240-
useFrame(() => {
241-
if (state.delta > state.eps) {
242-
group.current.style.transform = `translate3d(${
243-
state.horizontal ? -width * (state.pages - 1) * state.offset : 0
244-
}px,${state.horizontal ? 0 : height * (state.pages - 1) * -state.offset}px,0)`
245-
}
246-
})
247-
root.render(
248-
<div
249-
ref={group}
250-
style={{ ...style, position: 'absolute', top: 0, left: 0, willChange: 'transform' }}
251-
{...props}
252-
>
253-
<context.Provider value={state}>
254-
<fiberContext.Provider value={fiberState}>{children}</fiberContext.Provider>
255-
</context.Provider>
256-
</div>
257-
)
258-
return null
259-
}
260-
)
238+
const ScrollHtml: ForwardRefComponent<
239+
{ children?: React.ReactNode; style?: React.CSSProperties; pixelPerfect?: boolean },
240+
HTMLDivElement
241+
> = React.forwardRef(
242+
(
243+
{
244+
children,
245+
style,
246+
pixelPerfect,
247+
...props
248+
}: { children?: React.ReactNode; style?: React.CSSProperties; pixelPerfect?: boolean },
249+
ref
250+
) => {
251+
const state = useScroll()
252+
const group = React.useRef<HTMLDivElement>(null!)
253+
React.useImperativeHandle(ref, () => group.current, [])
254+
const { width, height } = useThree((state) => state.size)
255+
const fiberState = React.useContext(fiberContext)
256+
const root = React.useMemo(() => ReactDOM.createRoot(state.fixed), [state.fixed])
257+
258+
// Track velocity for pixelPerfect optimization
259+
const prevOffset = React.useRef(state.offset)
260+
261+
useFrame(() => {
262+
if (state.delta > state.eps) {
263+
const x = state.horizontal ? -width * (state.pages - 1) * state.offset : 0
264+
const y = state.horizontal ? 0 : height * (state.pages - 1) * -state.offset
265+
266+
// Calculate velocity by comparing current offset to previous frame
267+
const velocity = Math.abs(state.offset - prevOffset.current)
268+
prevOffset.current = state.offset
269+
270+
// Apply pixelPerfect rounding when velocity is very low (nearly stopped)
271+
const shouldApplyPixelPerfect = pixelPerfect && velocity < 0.001
272+
const finalX = shouldApplyPixelPerfect ? roundToPixelRatio(x) : x
273+
const finalY = shouldApplyPixelPerfect ? roundToPixelRatio(y) : y
274+
275+
group.current.style.transform = `translate3d(${finalX}px,${finalY}px,0)`
276+
}
277+
})
278+
root.render(
279+
<div ref={group} style={{ ...style, position: 'absolute', top: 0, left: 0, willChange: 'transform' }} {...props}>
280+
<context.Provider value={state}>
281+
<fiberContext.Provider value={fiberState}>{children}</fiberContext.Provider>
282+
</context.Provider>
283+
</div>
284+
)
285+
return null
286+
}
287+
)
261288

262289
interface ScrollPropsWithFalseHtml {
263290
children?: React.ReactNode
@@ -269,6 +296,7 @@ interface ScrollPropsWithTrueHtml {
269296
children?: React.ReactNode
270297
html: true
271298
style?: React.CSSProperties
299+
pixelPerfect?: boolean
272300
}
273301

274302
export type ScrollProps = ScrollPropsWithFalseHtml | ScrollPropsWithTrueHtml

0 commit comments

Comments
 (0)