Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add createSpring primitive. #629

Merged
merged 15 commits into from
Sep 26, 2024

Conversation

Blankeos
Copy link
Contributor

@Blankeos Blankeos commented May 12, 2024

Summary

  • This PR adds the createSpring primitive. Inspired and Forked from https://svelte.dev/docs/svelte-motion#spring
  • The API works exactly like createSignal, just that the setter will actually interpolate the value.
  • It also has familiar opts as the one with svelte-motion/spring.
  • I come from using Svelte and it's one of the most essential primitives I use for animation. I've been using Solid more and more and have come to love it. I think this primitive is one of the few missing for me here. I think Solid would greatly benefit from a primitive like this since we already have a tween primitive.

Example Usage:

export default function SpringyPage() {
  const [progress, setProgress] = createSpring(0);

  const [radialProgress, setRadialProgress] = createSpring(0, {
    stiffness: 0.05,
  });

  const [xy, setXY] = createSpring(
    { x: 50, y: 50 },
    { stiffness: 0.08, damping: 0.2, precision: 0.01 }
  );

  function toggleProgress() {
    if (progress() === 0) setProgress(1);
    else setProgress(0);
  }
  // ...

Witness, the springiness:

solidjs.spring.primitive.-.demo.mp4
Solid.CreateSpring.Demo.mp4

Todos:

  • Tests
  • README.md
  • package.json

Copy link

changeset-bot bot commented May 12, 2024

⚠️ No Changeset found

Latest commit: 0222ae1

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently it allows you to pass a string as an input (or an array of strings, object with string values etc), but you could use a type like this to provide some validation

type SpringTargetPrimitive = number | Date;
type SpringTarget =
  | SpringTargetPrimitive
  | { [key: string]: SpringTargetPrimitive | SpringTarget }
  | SpringTargetPrimitive[]
  | SpringTarget[];

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good suggestion. Was also considering this. I just made it close to Svelte's implementation which was more loose with types.

But under the hood it does actually only implement those specific primitives: Date, Number, Array<Date | number>, and { [key: any]: Date | number> }.

Will definitely add it here. Thanks!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While implementing this, I ran into a bit of a hiccup. Didn't know generics behaved this way lol. They seem to convert the data primitives into constants. (e.g. passing 0 will make the type 0, 1 will be 1, etc.)

image

Luckily the fix was easy:

image

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a side note but the "validation" will not catch this:

const [spring, setter] = createSpring({foo: [1,2,3]} as {foo: number[]} | number)
setter(101)
// runtime error: Cannot read properties of undefined (reading '0')

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And it seems to break with readonly number[]

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also interfaces

image

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also in source code there is a current_value == null check, which I think is impossible with the validation.

@Blankeos
Copy link
Contributor Author

Blankeos commented May 15, 2024

Update: I also figure of adding createDerivedSpring by the way, so that there's a similar API that works like the current createTween's API which doesn't return a setter, but takes in an Signal accessor.

const springValue = createDerivedSpring(signal, opts)

Will add this tonight.

Copy link
Member

@thetarnav thetarnav left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, this is a very nice addition.

packages/spring/src/index.ts Outdated Show resolved Hide resolved
packages/spring/src/index.ts Outdated Show resolved Hide resolved
packages/spring/src/index.ts Outdated Show resolved Hide resolved
packages/spring/src/index.ts Outdated Show resolved Hide resolved
packages/spring/src/index.ts Outdated Show resolved Hide resolved
packages/spring/test/index.test.ts Outdated Show resolved Hide resolved
packages/spring/test/index.test.ts Outdated Show resolved Hide resolved
packages/spring/test/index.test.ts Outdated Show resolved Hide resolved
packages/spring/package.json Outdated Show resolved Hide resolved
packages/spring/src/index.ts Outdated Show resolved Hide resolved
@Blankeos
Copy link
Contributor Author

Blankeos commented Sep 11, 2024

Hi @thetarnav just pushed the latest changes based on your review. Thanks so much for the very thorough comments on it.

I haven't done the tests related to timeouts yet because as you said, timeouts are kind of my nightmare too lol. I'll take a gander at it again soon (maybe tomorrow or in a few hours).

If you want to try playing around with it (with the same example video above). Try running this: https://github.com/blankeos/spring-solid-test (currently I just copy paste the index.ts I didn't do pnpm link or anything lol)

@thetarnav
Copy link
Member

I don't necessarly like that createSpring by default tries to handle various types of data recursively.
The core idea of a spring is just a simple math equation for tweening numbers, but the source code reads like something much more complex.
I wonder if it would be smart to separate it into more functions rather than one-does-all primitive. Maybe a separate primitive for numbers and objects?
It would be nice to have a vanilla-js spring(from, to, time, options) function as well.
Also maybe the primitive could be added to the tween package? The use and concepts are similar. Separate package for each tweening strategy would probably be a lot.
I still need to think about that.

@thetarnav
Copy link
Member

I tried to inline the tasks thing from svelte to the primitive. The tests pass and it works as it should in my eyes, but could you double check @Blankeos?
Probably will merge after that.

}

if (raf_id === 0) {
time_last = performance.now()
Copy link

@rcoopr rcoopr Sep 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it should be something like Number(document.timeline.currentTime || performance.now()) ala MDN

Otherwise, in cases where document.timeline.currentTime is available, the first frame could have a negative timeDelta

Copy link
Member

@thetarnav thetarnav Sep 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm youre right
I'm consistently getting negative values with performance.now.

image

But with document.timeline.currentTime it's always zero.
Which makes me think that maybe this would be better:

requestAnimationFrame(prev_time => {
	function frame(time) {
		let delta = time-prev_time
		prev_time = time
		requestAnimationFrame(frame)
	}
	requestAnimationFrame(frame)
})
// or
let prev_time = Infinity
function frame(time) {
	let delta = Math.max(0, time-prev_time)
	prev_time = time
	requestAnimationFrame(frame)
}
requestAnimationFrame(frame)

Since the first iteration will be ran with 0 delta anyway which won't progress the animation at all, so it could be skipped.
Instead of relying on document.timeline.currentTime, which may be and may not be there, and falling back to performance.now which is wrong.

}

if (opts.soft) {
inv_mass_recovery_rate = 1 / (typeof opts.soft === "number" ? opts.soft * 60 : 30);
Copy link

@rcoopr rcoopr Sep 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This hard-codes an assumed 60 fps into the inverse mass recovery feature. On different hardware, opts.soft will behave differently (inv_mass will return to 1 more quickly with higher framerate) and more importantly the velocity will be incorrect.

I'm not sure the best fix for fps-independence here, but in my own adaptation I have gone for tracking the fps as Math.max(fps, 1000 / (time - timeLast)) in the frame function above

See sveltejs/svelte#10717

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Turns out tracking fps this way is very flawed. At best it gives an inaccurate value (consistently getting ~180 fps when I should really be getting 166) and at worst is can go to extreme values and be very inconsistent.

I can look into frame-independent impls unless somebody else wants to jump in with a solution

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My intuition would be to keep the recovery rate as a constant, only depending on opts.soft.
And use time delta in inv_mass = Math.min(inv_mass + inv_mass_recovery_rate * time_delta, 1)
But I'm curious about other directions.
I'll try to add a test for framerate-independence since we are mocking the raf anyway.

Copy link

@rcoopr rcoopr Sep 24, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was able to fix the inv_mass_recovery_rate to actually recover in the correct amount of time, but couldn't accomplish the same for the tick function

const tick = (last: T, current: T, target: T): any => {
if (typeof current === "number" || is_date(current)) {
const delta = +target - +current;
const velocity = (+current - +last) / (time_delta || 1 / 60); // guard div by 0
const spring = stiffness * delta;
const damper = damping * velocity;
const acceleration = (spring - damper) * inv_mass;
const d = (velocity + acceleration) * time_delta;

The frame-dependent time_delta is baked into this implementation in tick. It doesn't quite cancel out even though d is multipled by time_delta in the end either, but I'm not totally sure what is supposed to be going on instead.

https://github.com/pqml/spring/blob/master/lib/Spring.js - this looks promising, it is based on picking a timestep, checking how many of those timesteps have passed since the last frame, and solving for the position that many times

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense. I'm using the same technique in my force-graph implementation—which has the same problem that it cannot be simply "multiplied by delta_time" to make the simulation frame-independent—and it works pretty well: https://github.com/thetarnav/force-graph/blob/main/index.mjs#L86-L98

Copy link
Member

@thetarnav thetarnav left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I’m going to merge this in the current state as it’s already useful and matches sveltes version.
The implementation could be improved to be frame-independent in future PRs as it shouldn’t change the current interface.

@thetarnav thetarnav merged commit d270e6b into solidjs-community:main Sep 26, 2024
3 checks passed
@Blankeos
Copy link
Contributor Author

Awesome work guys! Couldn't really check the past week, it's been a very busy month for me.

Thanks for adding in your insights @thetarnav @rcoopr! 🦾🥳

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants