You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
I've been using a custom template to quickly prototype vanilla Three.js apps, for example to follow along courses and replicate tutorials. I probably spent more time on it than I ought to, but that's a different story.
Because I use typescript, tweakpane was the perfect match to have a debug UI. However, my template also uses zustand to keep a centralized state management solution, and my particular case became incompatible with tweakpane.
Briefly, my problem was that it is not possible pass custom binding options to tweakpane to handle read/write operations in the configuration object. The default behavior bypasses zustand's setState action, and while using the on('change') event mostly works, I faced two specific problems:
Equality comparison functions break when using selectors because the previous and current values will always be the same. In other words, tweakpane first mutates the prop, then zustand sets the same value again in the change event.
Monitor bindings do not get updated in the UI. I'm not sure why this happens, but I assume tweakpane is not reading from the state.
I figured a number of solutions to make the two libraries compatible. Here I'd like to share my current approach using a custom plugin that adds two new configuration options, reader and writer, to the addBinding method from tweakpane. See the Final Words section at the end for a likely better approach.
Example
Let's say I have a simple object that I want to control via a zustand store. In that object, two primitives are used as uniforms in a custom shader, and I want to tweak them using the debug UI.
// I could in fact create the uniform directly in this object using the `Uniform` class and it// would probably work out of the box. But for the sake of the example and to keep things clean// I'm going to pretend that's not an option.exportconstworldSettings={uvDisplacementOffset: 5.0,uvStrengthOffset: 5.0,// other props...};
What I want is to be able to customize how these values are read or written by tweakpane by passing two reader and writer options. The API should be something like this:
In this case, the store is the created zustand store, but It could be anything else you want, these are just custom read/write options. Notice the target in the callback? This is the class that tweakpane uses internally to handle read/write operations to the object properties.
Implementation
The first thing I do is destructure the default NumberInputPlugin from @tweakpane/core.
import{NumberInputPluginasDefaultNumberInputPlugin}from'@tweakpane/core';const{
accept,// passes params to the binding
api,
binding,// defines reader and writer functions
controller,
core,
id,
type,}=DefaultNumberInputPlugin;
In this case, I only need to override two configs:
The accept function controls what type of value and specific options are passed to the plugin. Because we are addding two custom functions as options, we need to accept them so they reach the binding.
The binding function contains the default reader and writer functions that determine how the value is handled. This is where we can add the functionality to read and write the zustand state.
Accept Override
The accept function is pretty generic and can be made reusable across the different plugins.
import{parseRecord}from'@tweakpane/core';/** * Accept function return type (not exported from `@tweakpane/core`). */interfaceAcceptance<T,P>{initialValue: T;params: P;}/** * Accept function type (not exported from `@tweakpane/core`). */typeAcceptFunction<Ex,P>={/** * @param exValue The value input by users. * @param params The additional parameters specified by users. */(exValue: unknown,params: Record<string,unknown>): Acceptance<Ex,P>|null;};/** * Custom accept function that extends the default by passing the custom reader * and writer params to the rest of the plugin chain. * @param accept default accept function */exportconstcustomAccept=<Ex,P>(accept: AcceptFunction<Ex,P>): AcceptFunction<Ex,P>=>{return(value,params)=>{constresult=accept(value,params);if(result){result.params={
...result.params,
...parseRecord(params,(p)=>({reader: p.optional.function,writer: p.optional.function,})),};}returnresultasReturnType<typeofaccept>;};};
As you can see, the customAccept function is curried to receive the default accept function from the plugin we are about to override. In this example we're only overriding the default NumberInputPlugin, but this way it can be reused for other input types.
In the code above, the relevant bit is where we define the reader and writer params, which I chose to make optional. If none are passed, we want to revert to the default behavior, as we will see next in the custom functions.
// add the two params as optional functions
...parseRecord(params,(p)=>({reader: p.optional.function,writer: p.optional.function,})),// ...
Custom Reader/Writer
Creating the custom functions is quite easy in this case, since our values are just floats. Here we want to call the custom writer and reader functions, or if not defined just revert to the default binding method. Don't mind the custom types for now.
/** * Plugin type alias. */typeNumberInputPlugin=InputBindingPluginWithStateParams<typeofDefaultNumberInputPlugin>;typeCustomReader=GetReaderType<NumberInputPlugin>;typeCustomWriter=GetWriterType<NumberInputPlugin>;/** * Custom number input/monitor reader function. */constgetNumberReader: CustomReader=(args)=>{const_reader=args.params.reader;if(!_reader)returnbinding.reader(args);return(value)=>{return_reader(args.target,Number(value));};};/** * Custom number input writer function. */constgetNumberWriter: CustomWriter=(args)=>{const_writer=args.params.writer;if(!_writer)returnbinding.writer(args);return(target,value)=>{_writer(target,value);};};
As you can see, args.params is the object that contains the custom reader and writer functions. In addition to callling those, we may need more complex logic to hande object inputs (for example, a Color class).
The type helpers are there to satisfy typescript, so it knows that args.params contains the two new optional methods.
/** * Input binding plugin with custom reader and writer state parameters. */exporttypeInputBindingPluginWithStateParams<T>=TextendsInputBindingPlugin<infer In, infer Ex, infer Params>
? InputBindingPlugin<In,Ex,WithStateParams<Params,Ex>>
: never;/** * Get the reader type from the binding plugin. */exporttypeGetReaderType<T>=TextendsInputBindingPlugin<infer In, infer Ex, infer Params>
? T['binding']['reader']
: TextendsMonitorBindingPlugin<infer In, infer Params>
? T['binding']['reader']
: never;/** * Get the writer type from the input binding plugin. */exporttypeGetWriterType<T>=TextendsInputBindingPlugin<infer In, infer Ex, infer Params>
? T['binding']['writer']
: never;/** * Extend binding type args with params with custom reader and writer params. */typeWithStateParams<P,V>=P&{reader?: (target: BindingTarget,value: unknown)=>V;writer?: (target: BindingTarget,value: V)=>void;};
Custom Plugin
Now we have everything needed to create the plugin override.
exportconstNumberInputPlugin: NumberInputPlugin={id: id+'-state',// add whatever id here, or just use the default id
type,accept: customAccept(accept),binding: {constraint: binding.constraint,equals: binding.equals,reader: getNumberReader,writer: getNumberWriter,},
controller,
core,
api,};
We can then add that plugin to a bundle, along with any other plugin overrides.
import{TpPluginBundle}from'@tweakpane/core';exportconstStateBundle: TpPluginBundle={id: 'state-compatibility',plugins: [NumberInputPlugin,// NumberMonitorPlugin,// ColorNumberInputPlugin,// ColorObjectInputPlugin,// more overrides...],};
As a bonus, it is possible to have type inference for the new options in the addBinding method using module augmentation. Just reuse the WithStateParams type above. Tweakpane already allows to pass unknown options, so this is not really required.
Okay, maybe that was a lot. But once you get the first override going, it's quite easy to continue. The accept function and custom types are reusable, so keeping each plugin in its own file is maybe 60-80 lines each. Not too much.
Is there an easier way to do this? Yes, as I mentioned, it is possible to use the on('change') API to handle most cases. If you're not using selectors, then that's probably the smart approach.
Another possibility would be to create a custom BindingTarget class that overrides the read, write, and writeProperty methods with whatever you want. I thought about submitting an issue/PR combo to figure out if it's an option worth exploring. I imagine that implementation along the lines of a generic class:
reacted with thumbs up emoji reacted with thumbs down emoji reacted with laugh emoji reacted with hooray emoji reacted with confused emoji reacted with heart emoji reacted with rocket emoji reacted with eyes emoji
-
I've been using a custom template to quickly prototype vanilla Three.js apps, for example to follow along courses and replicate tutorials. I probably spent more time on it than I ought to, but that's a different story.
Because I use typescript, tweakpane was the perfect match to have a debug UI. However, my template also uses zustand to keep a centralized state management solution, and my particular case became incompatible with tweakpane.
Briefly, my problem was that it is not possible pass custom binding options to tweakpane to handle read/write operations in the configuration object. The default behavior bypasses zustand's
setState
action, and while using theon('change')
event mostly works, I faced two specific problems:I figured a number of solutions to make the two libraries compatible. Here I'd like to share my current approach using a custom plugin that adds two new configuration options,
reader
andwriter
, to theaddBinding
method from tweakpane. See the Final Words section at the end for a likely better approach.Example
Let's say I have a simple object that I want to control via a zustand store. In that object, two primitives are used as uniforms in a custom shader, and I want to tweak them using the debug UI.
What I want is to be able to customize how these values are read or written by tweakpane by passing two
reader
andwriter
options. The API should be something like this:In this case, the
store
is the created zustand store, but It could be anything else you want, these are just custom read/write options. Notice thetarget
in the callback? This is the class that tweakpane uses internally to handle read/write operations to the object properties.Implementation
The first thing I do is destructure the default
NumberInputPlugin
from@tweakpane/core
.In this case, I only need to override two configs:
accept
function controls what type of value and specific options are passed to the plugin. Because we are addding two custom functions as options, we need to accept them so they reach thebinding
.binding
function contains the defaultreader
andwriter
functions that determine how the value is handled. This is where we can add the functionality to read and write the zustand state.Accept Override
The
accept
function is pretty generic and can be made reusable across the different plugins.As you can see, the
customAccept
function is curried to receive the defaultaccept
function from the plugin we are about to override. In this example we're only overriding the defaultNumberInputPlugin
, but this way it can be reused for other input types.In the code above, the relevant bit is where we define the
reader
andwriter
params, which I chose to make optional. If none are passed, we want to revert to the default behavior, as we will see next in the custom functions.Custom Reader/Writer
Creating the custom functions is quite easy in this case, since our values are just floats. Here we want to call the custom
writer
andreader
functions, or if not defined just revert to the defaultbinding
method. Don't mind the custom types for now.As you can see,
args.params
is the object that contains the customreader
andwriter
functions. In addition to callling those, we may need more complex logic to hande object inputs (for example, aColor
class).The type helpers are there to satisfy typescript, so it knows that
args.params
contains the two new optional methods.Custom Plugin
Now we have everything needed to create the plugin override.
We can then add that plugin to a bundle, along with any other plugin overrides.
And register it as any other plugin.
Bonus
As a bonus, it is possible to have type inference for the new options in the
addBinding
method using module augmentation. Just reuse theWithStateParams
type above. Tweakpane already allows to pass unknown options, so this is not really required.Final words
Okay, maybe that was a lot. But once you get the first override going, it's quite easy to continue. The
accept
function and custom types are reusable, so keeping each plugin in its own file is maybe 60-80 lines each. Not too much.Is there an easier way to do this? Yes, as I mentioned, it is possible to use the
on('change')
API to handle most cases. If you're not using selectors, then that's probably the smart approach.Another possibility would be to create a custom
BindingTarget
class that overrides theread
,write
, andwriteProperty
methods with whatever you want. I thought about submitting an issue/PR combo to figure out if it's an option worth exploring. I imagine that implementation along the lines of a generic class:Anyway, I hope all this stuff helps someone. It's a generic read/write implementation, so the usage goes beyond zustand.
Thanks for reading so far. Any thoughts? Would do something differently?
Beta Was this translation helpful? Give feedback.
All reactions