Browse Source

initial commit

main
TFLCL 1 week ago
commit
caf9f41ee0
  1. 611
      tc.controller.js
  2. 6187
      tc.controller_demo.maxpat

611
tc.controller.js

@ -0,0 +1,611 @@
/* SPDX-License-Identifier: GPL-3.0-or-later */
/*
This js file is meant to be used in Cycling'74 Max with the [v8] object.
It provides a way to control cameras in a typical first-person fashion,
and move and turn objects in view-space.
Copyright (C) 2025 Théophile Clet <contact@tflcl.xyz> - https://tflcl.xyz
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details:
<https://www.gnu.org/licenses/gpl-3.0.txt>.
*/
autowatch = 1;
outlets = 2;
var drawto = "";
declareattribute({ name: "drawto", setter: "setdrawto" });
var ease = 0.1;
declareattribute({
name: "ease",
setter: "set_ease",
label: "Ease",
type: "float",
min: 0.0,
default: 0.1,
});
var camera_node = "";
declareattribute({
name: "camera_node",
setter: "camera",
label: "Camera anim node",
type: "string",
});
var flymode = 0;
declareattribute({
name: "flymode",
setter: "set_flymode",
style: "onoff",
default: 0,
});
var show_bounds = 1;
declareattribute({
name: "show_bounds",
setter: "set_show_bounds",
style: "onoff",
default: 1,
});
const ANIMABLE_GL_OBJECTS = [
"jit_gl_node",
"jit_gl_mesh",
"jit_gl_gridshape",
"jit_gl_model",
"jit_gl_plato",
"jit_gl_nurbs",
"jit_gl_text",
"jit_gl_videoplane",
"jit_gl_graph",
"jit_gl_sketch",
"jit_gl_path",
"jit_gl_lua",
"jit_gl_isosurf",
"jit_gl_cornerpin",
"jit_gl_skybox",
"jit_gl_volume",
"jit_gl_multiple",
"jit_gl_light",
"jit_gl_camera",
];
let controlled_gl_objects = []; // Used for enabling.disabling drawbounds on object(s) animated by the controller
let controllable_objects = []; // Used for listing all controllable objects in a patch
let top_level_patcher;
let ctlr;
class Controller {
constructor(drawto) {
this.ease = 0.1; // anim.drive easing
this.flymode = false;
this.flymode_applyed = false; // Flag
this.control_mode; // 'cam' if the camera is the target, 'obj' otherwise
this.world_up = [0, 1, 0];
this.camera_node = new JitterObject("jit.proxy");
this.cam_direction, this.cam_up, this.cam_right;
this.camera_base_matrix;
this.get_cam_base_matrix();
// main_node always has its Y axis aligned with world_up direction
// It is used with main_drive for up/down movements, Y rotation (yaw) and X-Y movements (accross an horizontal plan)
this.main_node = new JitterObject("jit.anim.node");
this.main_node.turnmode = "local";
this.main_node.movemode = "local";
this.main_node.tripod = 1; // for flymode
this.main_drive = new JitterObject("jit.anim.drive");
this.main_drive.drawto = drawto;
this.main_drive.ease = this.ease;
this.main_drive.targetname = this.main_node.name;
// pitch_node used with pitch_drive for pitch rotation (over the objects local x axis)
this.pitch_node = new JitterObject("jit.anim.node");
this.pitch_node.anim = this.main_node.name;
this.pitch_node.turnmode = "parent";
this.pitch_node.movemode = "parent";
this.pitch_drive = new JitterObject("jit.anim.drive");
this.pitch_drive.drawto = drawto;
this.pitch_drive.ease = this.ease;
this.pitch_drive.targetname = this.pitch_node.name;
this.target = new JitterObject("jit.proxy"); // Stores the object to control
}
// Taking control over a new target
control(obj_name, ctrl_mode) {
if (this.camera_node.class == "") {
error("No camera defined. Cannot control object.\n");
return;
}
let new_target;
if (obj_name != undefined) {
new_target = get_anim_node(obj_name, this.pitch_node.name);
}
if (
new_target != undefined &&
(this.target.name != "" || this.target.name != new_target)
) {
this.control_mode = ctrl_mode;
if (this.target.name != "") {
const transform = this.target.send("getworldtransform"); // Get the current targets tranfsorm,
this.target.send("anim"); // Unbind it (makes it loose its transform part coming from Controller nodes)
this.target.send("anim_reset"); // Reset it
this.target.send("transform", transform); // And re-apply the transform so it stays in place
}
this.target.name = new_target; // Bind to new target
this.reset(); // Reset this.Controller anim nodes
this.main_node.position = this.target.send("getworldpos"); // Take the target position, rotatexyz and scale and pass them to this.Controller anim nodes
let target_rotatexyz = this.target.send("getrotatexyz");
this.main_node.rotatexyz = [0, target_rotatexyz[1], 0];
this.pitch_node.rotatexyz = [target_rotatexyz[0], 0, target_rotatexyz[2]];
this.pitch_node.scale = this.target.send("getscale");
// And select the new target
this.target.send("anim_reset");
this.target.send("anim", this.pitch_node.name);
// Apply specific rules depending on if controlling the camera or an object
if (this.control_mode == "cam") {
this.main_node.movemode = "local";
this.main_node.turnmode = "local";
this.pitch_node.turnmode = "parent";
this.has_rotated = false;
this.set_flymode(this.flymode);
} else {
this.flymode_applyed = false;
// When controlling an object, we convert the translation vector
// from screen space to world space in this.move
// So we need the cam base matrix
this.main_node.movemode = "world";
this.main_node.turnmode = "world";
this.pitch_node.turnmode = "world";
this.get_cam_base_matrix();
}
// No target
} else if (obj_name == undefined && this.target.name != "") {
this.target.send("anim");
this.target.send("anim_reset");
this.target.send("transform", this.pitch_node.worldtransform);
this.target.name = "";
}
outlet(0, "control", obj_name, this.target.name);
}
camera(obj_name) {
const cam_anim_node_name = get_anim_node(obj_name, this.pitch_node.name);
if (cam_anim_node_name != undefined) {
this.camera_node.name = cam_anim_node_name;
camera_node = this.camera_node.name;
this.camera_node.send("animmode", "parent");
this.get_cam_base_matrix();
}
}
get_cam_base_matrix() {
if (this.camera_node.name != "") {
this.cam_direction = this.camera_node.send("getdirection");
this.cam_direction[1] = 0;
this.cam_direction = normalize(this.cam_direction);
this.cam_right = normalize(cross(this.cam_direction, this.world_up));
this.cam_up = normalize(cross(this.cam_right, this.cam_direction));
this.camera_base_matrix = [
this.cam_right,
this.cam_up,
this.cam_direction,
];
}
}
move(x, y, z) {
let translat_vec = [x, y, z];
if (this.control_mode == "obj") {
let cam_x_rot = this.camera_node.send("getrotatexyz")[0];
// Inverse translation direction if cam is upside down
if (cam_x_rot < -90 || cam_x_rot > 90) {
x *= -1;
z *= -1;
}
translat_vec = multiplyMatrixVector(this.camera_base_matrix, [x, 0, -z]);
}
this.main_drive.move(translat_vec[0], y, translat_vec[2]);
}
turn(x, y, z) {
let rot_vec = [x, y, z];
if (this.control_mode == "obj") {
rot_vec = multiplyMatrixVector(this.camera_base_matrix, [-x, 0, 0]);
rot_vec[1] = -y;
}
if (this.flymode && this.control_mode == "cam") {
// When flymode is enabled, we apply all rotations to the main_node
// so that the cam can move toward the direction it is pointing to
this.main_drive.turn(rot_vec[0], rot_vec[1], 0);
this.pitch_drive.turn(0, 0, 0);
} else {
this.main_drive.turn(0, rot_vec[1], 0);
this.pitch_drive.turn(rot_vec[0], 0, rot_vec[2]);
}
}
elev(y) {
this.main_drive.move([0, y, 0]);
}
set_flymode(v) {
// When enabled, the camera can move toward any direction it faces.
// When disabled, it should not move on the world y axis ("walk mode") unless explicitely sending move messages with non-null Y component.
if (v == 1) {
this.flymode = true;
} else {
this.flymode = false;
}
this.apply_flymode();
}
apply_flymode() {
if (this.control_mode == "cam") {
if (this.flymode) {
// When enabling flymode, we move all rotations to main_node
this.main_node.rotatexyz = [
this.pitch_node.rotatexyz[0],
this.main_node.rotatexyz[1],
0,
];
this.pitch_node.anim_reset();
this.flymode_applyed = true;
} else if (this.flymode_applyed) {
// And we revert when disabling
let rotatexyz = this.main_node.rotatexyz;
this.pitch_node.anim_reset();
this.pitch_node.rotatexyz = [rotatexyz[0], 0, 0];
this.main_node.rotatexyz = [0, rotatexyz[1], rotatexyz[2]];
this.flymode_applyed = false;
}
}
}
set_drawto(v) {
this.main_drive.drawto = v;
this.pitch_drive.drawto = v;
}
set_ease(v) {
this.ease = v;
this.main_drive.ease = this.ease;
this.pitch_drive.ease = this.ease;
}
reset() {
this.main_node.anim_reset();
this.pitch_node.anim_reset();
}
resync() {
if (this.target.name != "") {
this.control(this.target.name, this.control_mode);
}
}
destroy() {
if (this.target.name != "") {
this.target.send("anim");
this.target.send("transform", this.pitch_node.worldtransform);
}
this.main_node.freepeer();
this.main_drive.freepeer();
this.pitch_node.freepeer();
this.pitch_drive.freepeer();
this.target.freepeer();
}
}
loadbang();
// Select the object to use as basis for view-space transforms
function camera(name) {
ctlr.camera(name);
camera_node = ctlr.camera_node.name;
}
// Select which object to control in view-space
function control(obj_name) {
ctlr.control(obj_name, "obj");
if (show_bounds) {
do_show_bounds(0);
}
controlled_gl_objects = [];
get_gl_obj_controlled_by_ctlr();
if (show_bounds) {
do_show_bounds(show_bounds);
}
}
// Select which camera to control
function control_camera(obj_name) {
if (obj_name !== undefined) {
ctlr.control(obj_name, "cam");
do_show_bounds(0);
}
}
// Output from the second outlet a list of all controllable jit.gl and jit.anim.node objects in the entire patcher hierarchy
function controllable() {
controllable_objects = [];
top_level_patcher.applydeepif(
add_to_controllable_list,
is_controllable_applydeepif
);
outlet(1, "controllable");
for (let obj of controllable_objects) {
outlet(1, obj.maxclass, obj.getattr("name"));
}
outlet(1, "done");
}
function add_to_controllable_list(obj) {
controllable_objects.push(obj);
}
add_to_controllable_list.local = 1;
function is_controllable_applydeepif(obj) {
return (
obj.maxclass == "jit.anim.node" ||
is_controllable(obj.maxclass.replaceAll(".", "_"))
);
}
is_controllable_applydeepif.local = 1;
function resync() {
ctlr.resync();
}
function reset() {
ctlr.reset();
}
function move(x, y, z) {
ctlr.move(x, y, z);
}
function turn(x, y, z) {
ctlr.turn(x, y, z);
}
function elev(v) {
ctlr.elev(v);
}
function set_ease(v) {
ctlr.set_ease(v);
ease = v;
}
function set_flymode(v) {
flymode = v == undefined ? !flymode : v == 1; // If flymode with no argument is provided, act as a toggle
ctlr.set_flymode(flymode);
}
function set_show_bounds(v) {
show_bounds = v == true;
// ctlr.show_bounds = show_bounds;
do_show_bounds(show_bounds);
}
function do_show_bounds(v) {
const objects_without_bounds = [
"jit.gl.sketch",
"jit.gl.skybox",
"jit.gl.camera",
];
for (const obj of controlled_gl_objects) {
if (!objects_without_bounds.includes(obj.maxclass)) {
obj.setattr("drawbounds", v);
}
}
}
do_show_bounds.local = 1;
function bang() {
outlet(0, ctlr.pitch_node.worldtransform);
}
function loadbang() {
if (!top_level_patcher) {
top_level_patcher = get_top_level_patcher();
ctlr = new Controller(drawto);
}
}
function notifydeleted() {
ctlr.destroy();
implicit_tracker.freepeer();
}
/////////////////////////////////////////////
// HELPER METHODS
/////////////////////////////////////////////
function get_anim_node(jit_obj_name, ctlr_node) {
const proxy = new JitterObject("jit.proxy");
proxy.name = jit_obj_name;
const proxy_class = proxy.class;
proxy.freepeer();
if (proxy_class == "jit_anim_node") {
return jit_obj_name;
} else if (proxy_class != "" && is_controllable(proxy_class)) {
const context_anim = get_context_anim(jit_obj_name);
return get_top_level_anim_node(jit_obj_name, context_anim, ctlr_node);
}
return;
}
get_anim_node.local = 1;
function is_controllable(obj_class) {
// Using jit.proxy syntax (ie 'jit_gl_mesh' and not 'jit.gl.mesh')
return ANIMABLE_GL_OBJECTS.includes(obj_class);
}
is_controllable.local = 1;
function get_context_anim(jit_obj_name) {
// Get the first implicit jit.anim.node level for the object's rendering context
const proxy = new JitterObject("jit.proxy");
proxy.name = jit_obj_name;
proxy.name = proxy.send("getdrawto");
const context_name = proxy.send("getanim");
proxy.freepeer();
return context_name;
}
get_context_anim.local = 1;
function get_top_level_anim_node(jit_obj_name, context_anim, ctlr_node) {
// Recursively get the top level jit.anim.node before reaching context_anim, or no anim.node, or this.pitch_node (if trying to controller currently controlled object)
const proxy = new JitterObject("jit.proxy");
proxy.name = jit_obj_name;
const parent_name = proxy.send("getanim").toString();
proxy.freepeer();
if (
parent_name == "" ||
parent_name == context_anim ||
parent_name == ctlr_node
) {
return jit_obj_name;
} else {
return get_top_level_anim_node(parent_name, context_anim, ctlr_node);
}
}
get_top_level_anim_node.local = 1;
function get_top_level_patcher() {
let prev = 0;
let owner = this.patcher.box;
while (owner) {
prev = owner;
owner = owner.patcher.box;
}
return prev ? prev.patcher : this.patcher;
}
get_top_level_patcher.local = 1;
// Get gl object(s) controlled by ctlr (for the sole purpose of drawing bounds of selected object)
function get_gl_obj_controlled_by_ctlr() {
top_level_patcher.applydeepif(
add_to_controlled_obj_list,
is_controlled_by_ctlr
);
}
get_gl_obj_controlled_by_ctlr.local = 1;
function is_controlled_by_ctlr(obj) {
if (is_controllable(obj.maxclass.replaceAll(".", "_"))) {
const obj_anim = obj.getattr("anim");
if (obj_anim == ctlr.pitch_node.name) {
return true;
} else if (obj_anim != "") {
const proxy = new JitterObject("jit.proxy");
proxy.name = obj_anim;
const parent_name = proxy.send("getanim").toString();
proxy.freepeer();
return parent_name == ctlr.pitch_node.name;
}
}
return false;
}
is_controlled_by_ctlr.local = 1;
function add_to_controlled_obj_list(obj) {
controlled_gl_objects.push(obj);
}
add_to_controlled_obj_list.local = 1;
/////////////////////////////////////////////
// MATHS
/////////////////////////////////////////////
function normalize(v) {
const len = Math.sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]);
if (len === 0) return v;
return [v[0] / len, v[1] / len, v[2] / len];
}
normalize.local = 1;
function cross(a, b) {
return [
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
];
}
cross.local = 1;
function dot(a, b) {
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
}
dot.local = 1;
function multiplyMatrixVector(matrix, vector) {
return [
dot(matrix[0], vector),
dot(matrix[1], vector),
dot(matrix[2], vector),
];
}
multiplyMatrixVector.local = 1;
/////////////////////////////////////////////
// GL Context
/////////////////////////////////////////////
let implicitdrawto = "";
let explicitdrawto = false;
const implicit_tracker = new JitterObject("jit_gl_implicit");
const implicit_lstnr = new JitterListener(
implicit_tracker.name,
implicit_callback
);
function implicit_callback(event) {
if (!explicitdrawto && implicitdrawto != implicit_tracker.drawto[0]) {
// important! drawto is an array so get first element
implicitdrawto = implicit_tracker.drawto[0];
dosetdrawto(implicitdrawto);
}
}
implicit_callback.local = 1;
function setdrawto(val) {
explicitdrawto = true;
dosetdrawto(val);
}
setdrawto.local = 1;
function dosetdrawto(arg) {
if (arg === drawto || !arg) {
// bounce
return;
}
drawto = arg;
ctlr.set_drawto(drawto);
}
dosetdrawto.local = 1;

6187
tc.controller_demo.maxpat

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save