Animating `innerRadius` on a RingGeometry with CustomPropertyAnimation in Niantic Studio

Hi 8th Wall team,

I’m trying to tween my portal’s ring innerRadius using:

 ecs.CustomPropertyAnimation.set(world, portalHiderRing, {
  property: 'innerRadius',
  to: 3.5,
  autoFrom: true,
  duration: 1500,
  easeOut: true,
  easingFunction: 'Elastic',
});

But there’s no “RingGeometry” attribute to specify, so the animation never runs. Right now I have to fall back to:

ecs.RingGeometry.set(world, portalHiderRing, {
 innerRadius: 3.5,
 outerRadius: 20,
});

Is there a supported way to animate RingGeometry properties (innerRadius/outerRadius) directly with CustomPropertyAnimation? If so, what attribute name should I use or what’s the recommended approach?

Thanks!

Your best bet is to use a custom curve function and change the innerRadius in a mutate function on tick.

For example:

function easeOutCubic(x: number): number {
return 1 - Math.pow(1 - x, 3);
}

Check out https://easings.net/ if you want to see all the functions.

Ok cool thank you! I’ll see how I can implement that!

Hi George,

Thanks again for your suggestion to use a custom curve function and update the innerRadius in an onTick. I’ve implemented that approach in my component, like this:

// This is a component file. You can use this file to define a custom component for your project.
// This component will appear as a custom component in the editor.

import * as ecs from '@8thwall/ecs'  // This is how you access the ecs library.

// Utils Function
function easeOutElastic(x: number): number {
  const c4 = (2 * Math.PI) / 3
  return x === 0
    ? 0
    : x === 1
      ? 1
      : 2 ** (-10 * x) * Math.sin((x * 10 - 0.75) * c4) + 1
}

// Component

ecs.registerComponent({
  name: 'Tap To Place Portal',
  schema: {
    portalContent: ecs.eid,
    portalHiderRing: ecs.eid,
    portalRim: ecs.eid,
    portalShadow: ecs.eid,
    uiTapToPlacePrompt: ecs.eid,
    uiResetButton: ecs.eid,
    uiOverlay: ecs.eid,
  },
  // schemaDefaults: {

  // },
  data: {
    startElapsed: ecs.f64,  // record when portalPlaced begins
  },
  // add: (world, component) => {
  // },
  // tick: (world, component) => {
  // },
  // remove: (world, component) => {
  // },
  stateMachine: ({world, eid, schemaAttribute, dataAttribute}) => {
    const {
      portalContent,
      portalHiderRing, portalRim,
      portalShadow, uiTapToPlacePrompt,
      uiResetButton, uiOverlay,
    } = schemaAttribute.get(eid)
    // DEFAULT: hide everything, then after 2s move to awaitTap
    ecs.defineState('Default')
      .initial()
      .onEnter(() => {
        console.log('Entered Default State')

        // hide portal content
        ecs.Hidden.set(world, portalContent)

        // collapse portal visuals
        ecs.Scale.set(world, portalRim, {
          x: 0.001,
          y: 0.001,
          z: 0.001,
        })

        ecs.Scale.set(world, portalShadow, {
          x: 0.001,
          y: 0.001,
          z: 0.001,
        })

        ecs.RingGeometry.set(world, portalHiderRing, {
          innerRadius: 0.01,
          outerRadius: 20,
        })
        // hide UI
        ecs.Hidden.set(world, uiResetButton)
        ecs.Hidden.set(world, uiTapToPlacePrompt)
      })

      .wait(1000, 'awaitTap')

    // AWAIT TAP: show the “tap to place” prompt
    ecs.defineState('awaitTap')
      .onEnter(() => {
        console.log('Entered awaitTap State')

        ecs.Hidden.remove(world, uiTapToPlacePrompt)
      })
      // when screen is tapped, animate portal and go to portalPlaced
      .onEvent(
        ecs.input.UI_CLICK,
        'portalPlaced',
        {target: uiOverlay}
      )

    // PORTAL PLACED: keep portal open and show reset button
    ecs.defineState('portalPlaced')
      .onEnter(() => {
        // Recenter the world origin at the camera’s current pose
        const {XR8} = window as any
        if (XR8 && XR8.XrController) {
          XR8.XrController.recenter()
        }
        const data = dataAttribute.cursor(eid)
        data.startElapsed = world.time.elapsed

        // show portal content
        ecs.Hidden.remove(world, portalContent)

        // run your placement animations

        ecs.ScaleAnimation.set(world, portalRim, {
          toX: 4.3,
          toY: 4.3,
          toZ: 4.3,
          autoFrom: true,
          duration: 1500,
          loop: false,
          reverse: false,
          easeIn: false,
          easeOut: true,
          easingFunction: 'Elastic',
        })

        ecs.ScaleAnimation.set(world, portalShadow, {
          toX: 15.5,
          toY: 2,
          toZ: 11,
          autoFrom: true,
          duration: 1500,
          loop: false,
          reverse: false,
          easeIn: false,
          easeOut: true,
          easingFunction: 'Elastic',
        })
        // hide prompt
        ecs.Hidden.set(world, uiTapToPlacePrompt)
        // reveal reset button
        ecs.Hidden.remove(world, uiResetButton)
      })

    // onTick to animate innerRadius over the same ms as the other annimations

      .onTick(() => {
        const data = dataAttribute.cursor(eid)
        const elapsed = world.time.elapsed - data.startElapsed
        const duration = 1500

        // compute progress 0→1
        const progress = Math.min(elapsed / duration, 1)

        if (progress < 1) {
          // apply elastic easing
          const t = easeOutElastic(progress)
          const radius = 0.01 + (3.5 - 0.01) * t

          ecs.RingGeometry.set(world, portalHiderRing, {
            innerRadius: radius,
            outerRadius: 20,
          })
        } else {
          // final snap once
          ecs.RingGeometry.set(world, portalHiderRing, {
            innerRadius: 3.5,
            outerRadius: 20,
          })
        }
      })
      // when the reset‐button entity receives a UI_CLICK, go back to Default
      .onEvent(
        ecs.input.UI_CLICK,
        'Default',
        {target: uiResetButton}
      )
  },
})

Even with this setup, where the onTick uses the same duration and the easeOutElastic function as the ScaleAnimations, the animation of the innerRadius still seems a bit out of sync with the scaling of the rim and shadow.

Do you have any ideas why this might be happening, or if there’s anything in how I’ve implemented the onTick that could be causing this subtle desync? Is there a better way to ensure manual property updates stay perfectly synchronized with the built-in animation components in the ECS?

Thanks again for your help and guidance on this!

Best,

Your use of dataAttribute might be causing it here.

Typically you want to destructure the elements from the dataAttribute you want to use:

const {startElapased} = dataAttribute.get(eid)
1 Like

i tried that and it’s still not in sync so i added 400ms to the custom tick animation and it looks to be in sync with the others. Maybe it’s the difference between how ScaleAnimations are implemented I’m not sure ?