-
-
Notifications
You must be signed in to change notification settings - Fork 2
Description
Summary
Add a useClipMask React hook to provide declarative clipping mask functionality using Two.js's mask property. This will enable developers to create masked effects, reveal animations, and viewport clipping without manually managing mask relationships.
Motivation
Two.js supports clipping masks through the shape.mask = clipPath property, where any shape or group can be masked by a Two.Polygon (or other shapes). However, there's currently no declarative React way to:
- Apply masks to shapes/groups
- Update masks when shapes change
- Clean up mask references on unmount
- Handle mask relationships across components
A React hook would make mask management declarative, type-safe, and integrated with React's lifecycle.
Two.js Clipping Mask Background
How Two.js masks work:
- Any Two.js object can have a
maskproperty:shape.mask = maskShape - The mask's matrix is multiplied by the matrix of the shape it's masifying
- Common pattern:
group.mask = polygonto clip a group's contents - Browser limitations: Currently most flexible with Two.Polygon as mask
- Affects all renderers: SVG (clipPath), Canvas (clip), WebGL (stencil buffer)
Example usage in vanilla Two.js:
const circle = two.makeCircle(0, 0, 50);
const mask = two.makePolygon(0, 0, 30);
circle.mask = mask;Proposed API
Hook Signature
interface UseClipMaskOptions {
enabled?: boolean; // Toggle mask on/off
updateOnChange?: boolean; // Re-apply mask when deps change
}
interface ClipMaskControls {
applyMask: (target: RefShape | RefGroup, mask: RefShape | RefGroup) => void;
clearMask: (target: RefShape | RefGroup) => void;
hasMask: (target: RefShape | RefGroup) => boolean;
currentMask: RefShape | RefGroup | null;
}
function useClipMask(
targetRef?: RefObject<RefShape | RefGroup>,
maskRef?: RefObject<RefShape | RefGroup>,
options?: UseClipMaskOptions
): ClipMaskControlsUsage Examples
Basic - Imperative Style
import { Canvas, Circle, Rectangle, useClipMask } from 'react-two.js';
function ClippedShape() {
const shapeRef = useRef<RefCircle>(null);
const maskRef = useRef<RefRectangle>(null);
const { applyMask } = useClipMask();
useEffect(() => {
if (shapeRef.current && maskRef.current) {
applyMask(shapeRef.current, maskRef.current);
}
}, [applyMask]);
return (
<>
{/* The visible shape (will be clipped) */}
<Circle ref={shapeRef} radius={100} fill="blue" />
{/* The mask (defines visible area) */}
<Rectangle
ref={maskRef}
width={50}
height={150}
fill="transparent"
/>
</>
);
}Declarative Style (Auto-apply)
function AutoClippedShape() {
const shapeRef = useRef<RefCircle>(null);
const maskRef = useRef<RefPolygon>(null);
// Automatically applies mask when both refs are available
useClipMask(shapeRef, maskRef);
return (
<>
<Circle ref={shapeRef} radius={100} fill="red" x={200} />
<Polygon ref={maskRef} sides={6} radius={60} x={200} />
</>
);
}Animated Reveal Effect
function RevealAnimation() {
const imageRef = useRef<RefImage>(null);
const maskRef = useRef<RefCircle>(null);
const [radius, setRadius] = useState(0);
useClipMask(imageRef, maskRef);
useEffect(() => {
// Animate reveal
const interval = setInterval(() => {
setRadius(r => Math.min(r + 2, 200));
}, 16);
return () => clearInterval(interval);
}, []);
return (
<>
<Image ref={imageRef} src="/photo.jpg" />
<Circle ref={maskRef} radius={radius} />
</>
);
}Dynamic Mask Switching
function DynamicMask() {
const shapeRef = useRef<RefRectangle>(null);
const circleMaskRef = useRef<RefCircle>(null);
const starMaskRef = useRef<RefStar>(null);
const [maskType, setMaskType] = useState<'circle' | 'star'>('circle');
const { applyMask, clearMask } = useClipMask();
useEffect(() => {
if (!shapeRef.current) return;
clearMask(shapeRef.current);
const mask = maskType === 'circle'
? circleMaskRef.current
: starMaskRef.current;
if (mask) {
applyMask(shapeRef.current, mask);
}
}, [maskType, applyMask, clearMask]);
return (
<>
<button onClick={() => setMaskType('circle')}>Circle Mask</button>
<button onClick={() => setMaskType('star')}>Star Mask</button>
<Rectangle ref={shapeRef} width={200} height={200} fill="blue" />
<Circle ref={circleMaskRef} radius={80} />
<Star ref={starMaskRef} outerRadius={80} innerRadius={40} />
</>
);
}Group Masking (Advanced)
function MaskedGroup() {
const groupRef = useRef<RefGroup>(null);
const maskRef = useRef<RefPolygon>(null);
useClipMask(groupRef, maskRef);
return (
<>
<Group ref={groupRef}>
{/* Everything in this group will be clipped */}
<Circle radius={50} fill="red" x={-30} />
<Circle radius={50} fill="blue" x={30} />
<Rectangle width={100} height={20} fill="green" y={60} />
</Group>
<Polygon ref={maskRef} sides={6} radius={100} />
</>
);
}Implementation Phases
Phase 1: Core Hook Structure
Files: lib/hooks/useClipMask.ts (new file)
- Create
lib/hooks/directory for utility hooks - Define TypeScript interfaces:
UseClipMaskOptionsClipMaskControls- Type guards for RefShape and RefGroup
- Implement basic hook structure with:
useRefto track current mask relationshipuseTwo()context integration- Return object with method stubs
- Export hook from new file
Deliverable: Hook skeleton with TypeScript types
Phase 2: Apply/Clear Mask Logic
Files: lib/hooks/useClipMask.ts
- Implement
applyMask(target, mask)method:- Validate both refs exist
- Set
target.mask = maskin Two.js - Store relationship in hook state
- Handle edge cases (null refs, same mask)
- Implement
clearMask(target)method:- Set
target.mask = null - Clear stored relationship
- Handle already-cleared masks
- Set
- Implement
hasMask(target)utility:- Check if target has mask applied
- Return boolean
- Implement
currentMaskgetter:- Return currently applied mask or null
Deliverable: Working imperative mask application
Phase 3: Declarative Auto-Apply
Files: lib/hooks/useClipMask.ts
- Add optional
targetRefandmaskRefparameters to hook - Implement
useEffectfor auto-apply:- Watch targetRef and maskRef
- Automatically call
applyMaskwhen both available - Clean up on unmount or ref changes
- Handle ref changes:
- Clear old mask when target changes
- Re-apply when mask changes
- Implement
enabledoption:- Toggle mask on/off without removing refs
- Useful for conditional masking
Deliverable: Declarative mask application with auto-cleanup
Phase 4: Advanced Features & Edge Cases
Files: lib/hooks/useClipMask.ts
- Handle mask matrix transformations:
- Ensure mask transforms are applied correctly
- Test with rotated/scaled masks
- Add warnings for common mistakes:
- Warn if mask shape is not added to scene
- Warn if using Canvas renderer with complex masks
- Warn about browser clipPath limitations
- Implement
updateOnChangeoption:- Re-apply mask when shape properties change
- Use dependency array for updates
- Handle multiple masks on same target (last wins)
- Support mask chaining (mask has a mask)
Deliverable: Robust mask handling with edge case coverage
Phase 5: Integration & Export
Files: lib/main.ts, lib/hooks/index.ts
- Create
lib/hooks/index.tsfor hook exports - Export
useClipMaskfromlib/main.ts - Export TypeScript interfaces
- Verify tree-shaking works
- Update package exports if needed
Deliverable: Hook available in public API
Phase 6: Testing
Files: lib/hooks/__tests__/useClipMask.test.tsx (new file)
- Test imperative mask application:
applyMask()sets mask propertyclearMask()removes maskhasMask()returns correct boolean
- Test declarative auto-apply:
- Mask applied when refs available
- Mask cleared on unmount
- Mask updates when refs change
- Test with different shape types:
- Circle, Rectangle, Polygon as masks
- Groups as targets
- Groups as masks
- Test edge cases:
- Null refs
- Changing masks dynamically
- Multiple masks
- Disabled state
- Mock Two.js shapes for testing
Deliverable: Comprehensive test coverage
Phase 7: Documentation & Examples
Files: README.md, src/App.tsx, documentation site
- Add
useClipMasksection to main README - Document all methods and options
- Create interactive examples:
- Basic clipping mask demo
- Animated reveal effect
- Dynamic mask switching
- Group masking
- Image masking
- Document browser limitations:
- SVG clipPath constraints
- Canvas renderer considerations
- WebGL stencil buffer behavior
- Add troubleshooting section
- Include performance considerations
- TypeScript usage examples
Deliverable: Complete documentation with examples
Technical Considerations
Two.js Mask Implementation
Core mechanism:
// Two.js internal behavior
shape.mask = maskShape;
// Renderer-specific implementation:
// SVG: Uses <clipPath> element
// Canvas: Uses ctx.clip()
// WebGL: Uses stencil bufferMatrix transformations:
- Mask's transformation matrix is multiplied by target's matrix
- Both mask and target positions/rotations affect final clipping region
- Masks inherit parent group transformations
Design Decisions
- Hook-based approach: Follows React patterns, composable, reusable
- Dual API: Both imperative (
applyMask) and declarative (auto-apply with refs) - Type-safe: Full TypeScript support for all shapes/groups
- Lifecycle-aware: Automatic cleanup on unmount
- Flexible: Works with any Two.js shape or group
- Ref-based: Integrates with existing component ref patterns
- Optional automation: Can be fully manual or fully automatic
Browser & Renderer Limitations
SVG Renderer:
- Most mature clipping support
- Supports nested clipPaths (some browser limitations)
- Complex shapes work well
Canvas Renderer:
- Uses
ctx.clip()for clipping - Some limitations with complex paths
- Performance considerations for frequent updates
WebGL Renderer:
- Uses stencil buffer for masking
- Limited to simpler mask shapes
- Performance varies by GPU
Performance Considerations
- Masks add rendering overhead (especially Canvas/WebGL)
- Frequent mask changes can be expensive
- Consider reusing mask shapes when possible
- Complex masks (many vertices) impact performance
- Group masking affects all children
Alternative Approaches Considered
1. Mask as Component Prop:
<Circle radius={50} mask={maskRef} />Pros: Declarative, concise
Cons: Breaks existing component API, requires ref before render
Decision: Hook provides more flexibility without breaking changes
2. MaskProvider Context:
<MaskProvider mask={maskShape}>
<Circle radius={50} />
</MaskProvider>Pros: Applies mask to multiple children easily
Cons: Less explicit, harder to control individual masks
Decision: Hook is more explicit and flexible
3. Component Wrapper:
<Masked mask={maskShape}>
<Circle radius={50} />
</Masked>Pros: Very declarative, clear hierarchy
Cons: Adds extra component layer, complex implementation
Decision: Hook is simpler and more performant
Resources
- Two.js GitHub Issue #56 - Clipping Masks
- Two.js Examples - Clipping Mask Demo
- Two.js Shape Documentation
- MDN - Canvas clip()
- MDN - SVG clipPath
Success Criteria
-
useClipMaskhook successfully applies masks to shapes/groups - Both imperative and declarative APIs work correctly
- Masks update properly when refs change
- Automatic cleanup on unmount
- Works with all shape types and groups
- Supports dynamic mask switching
- Full TypeScript support with proper types
- Comprehensive tests with good coverage
- Documentation includes examples and API reference
- No breaking changes to existing API
- Performance equivalent to manual Two.js mask usage
- Warnings for common mistakes and limitations
Labels: enhancement, feature request, hooks
Milestone: v0.3.0