Skip to content

Feature Request: Add useClipMask hook for declarative clipping masks #9

@jonobr1

Description

@jonobr1

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 mask property: shape.mask = maskShape
  • The mask's matrix is multiplied by the matrix of the shape it's masifying
  • Common pattern: group.mask = polygon to 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
): ClipMaskControls

Usage 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:
    • UseClipMaskOptions
    • ClipMaskControls
    • Type guards for RefShape and RefGroup
  • Implement basic hook structure with:
    • useRef to track current mask relationship
    • useTwo() 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 = mask in 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
  • Implement hasMask(target) utility:
    • Check if target has mask applied
    • Return boolean
  • Implement currentMask getter:
    • Return currently applied mask or null

Deliverable: Working imperative mask application


Phase 3: Declarative Auto-Apply

Files: lib/hooks/useClipMask.ts

  • Add optional targetRef and maskRef parameters to hook
  • Implement useEffect for auto-apply:
    • Watch targetRef and maskRef
    • Automatically call applyMask when both available
    • Clean up on unmount or ref changes
  • Handle ref changes:
    • Clear old mask when target changes
    • Re-apply when mask changes
  • Implement enabled option:
    • 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 updateOnChange option:
    • 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.ts for hook exports
  • Export useClipMask from lib/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 property
    • clearMask() removes mask
    • hasMask() 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 useClipMask section 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 buffer

Matrix 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

  1. Hook-based approach: Follows React patterns, composable, reusable
  2. Dual API: Both imperative (applyMask) and declarative (auto-apply with refs)
  3. Type-safe: Full TypeScript support for all shapes/groups
  4. Lifecycle-aware: Automatic cleanup on unmount
  5. Flexible: Works with any Two.js shape or group
  6. Ref-based: Integrates with existing component ref patterns
  7. 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

Success Criteria

  • useClipMask hook 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions