p5.js icon indicating copy to clipboard operation
p5.js copied to clipboard

The ability to construct custom `p5.Geometry` using existing modeling commands.

Open sflanker opened this issue 2 years ago • 6 comments

How would this new feature help [increase access]

Currently creation of custom 3d objects either has poor performance (drawing multiple primitives, or using beginShape/endShape), or requires separate work in a 3d modeling tool such as Blender. For users who are new to 3d graphics and would like to create complex models procedurally without having to use a separate piece of software it would be beneficial to be able to create p5.Geometry objects for complex shapes purely within p5.js and get the same performance that they would with a model created in Blender and imported with loadModel.

Inspiration for this feature request: https://stackoverflow.com/questions/68898347/p5-js-3d-dot-diagram-rendering-extremely-slow

Most appropriate sub-area of p5.js?

  • [ ] Accessibility (Web Accessibility)
  • [ ] Build tools and processes
  • [ ] Color
  • [ ] Core/Environment/Rendering
  • [ ] Data
  • [ ] DOM
  • [ ] Events
  • [ ] Friendly error system
  • [ ] Image
  • [ ] IO (Input/Output)
  • [ ] Localization
  • [ ] Math
  • [ ] Unit Testing
  • [ ] Typography
  • [ ] Utilities
  • [x] WebGL
  • [ ] Other (specify if possible)

New feature details:

Consider the following code that draws an icosahedron:

const PHI = (1 + Math.sqrt(5)) / 2;

let vertices = [
  [0, 1, PHI],

  [PHI, 0, 1],
  [0, -1, PHI],
  [-PHI, 0, 1],
  [-1, PHI, 0],
  [1, PHI, 0],

  [PHI, 0, -1],
  [1, -PHI, 0],
  [-1, -PHI, 0],
  [-PHI, 0, -1],
  [0, 1, -PHI],

  [0, -1, -PHI],
];

// ...

draw() {
  // ...

  beginShape(TRIANGLES);
  for (let i = 0; i < 5; i++) {
    vertex(...vertices[i + 1]);
    vertex(...vertices[i + 6]);
    let n = (i + 1) % 5;
    vertex(...vertices[n + 6]);

    vertex(...vertices[i + 1]);
    vertex(...vertices[n + 1]);
    vertex(...vertices[0]);
  }
  for (let i = 0; i < 5; i++) {
    vertex(...vertices[i + 6]);
    vertex(...vertices[i + 1]);
    let n = (i - 1);
    if (n < 0) {
      n = 4;
    }
    vertex(...vertices[n + 1]);

    vertex(...vertices[i + 6]);
    vertex(...vertices[n + 6]);
    vertex(...vertices[11]);
  }
  endShape();
}

This code would get more complicated if we also wanted to specify vertex normals, and if we wanted to draw many of these it would be detrimental for performance. Converting this to p5.Geometry is possible but neither well documented nor trivial:

const PHI = (1 + Math.sqrt(5)) / 2;

let vertices = [
  [0, 1, PHI],

  [PHI, 0, 1],
  [0, -1, PHI],
  [-PHI, 0, 1],
  [-1, PHI, 0],
  [1, PHI, 0],

  [PHI, 0, -1],
  [1, -PHI, 0],
  [-1, -PHI, 0],
  [-PHI, 0, -1],
  [0, 1, -PHI],

  [0, -1, -PHI],
];

// ...

let geom;

setup() {
  // ...
  
  geom = new p5.Geometry(1, 1, constructIcosahedron);
  // This is used as the key for caching vertex buffers. Make sure it is unique.
  geom.gid = 'icosahedron';
  // Automatically calculate normals based on faces.
  geom.computeNormals();
}

function constructIcosahedron() {
  for (let v of vertices) {
    this.vertices.push(createVector(...v));
  }
  for (let i = 0; i < 5; i++) {
    let n = (i + 1) % 5;
    this.faces.push([
      i + 1,
      i + 6,
      n + 6
    ]);
    
    this.faces.push([
      i + 1,
      n + 1,
      0
    ]);
  }
  for (let i = 0; i < 5; i++) {
    let n = (i - 1);
    if (n < 0) {
      n = 4;
    }
    this.faces.push([
      i + 6,
      i + 1,
      n + 1
    ]);

    this.faces.push([
      i + 6,
      n + 6,
      11
    ]);
  }
}

draw() {
  // ...

  model(geom);
}

What I propose is something like this:

const PHI = (1 + Math.sqrt(5)) / 2;

let vertices = [
  [0, 1, PHI],

  [PHI, 0, 1],
  [0, -1, PHI],
  [-PHI, 0, 1],
  [-1, PHI, 0],
  [1, PHI, 0],

  [PHI, 0, -1],
  [1, -PHI, 0],
  [-1, -PHI, 0],
  [-PHI, 0, -1],
  [0, 1, -PHI],

  [0, -1, -PHI],
];

// ...

let geom;

setup() {
  // ...
  
  geom = createGeometry();
  geom.beginShape(TRIANGLES);
  for (let i = 0; i < 5; i++) {
    geom.vertex(...vertices[i + 1]);
    geom.vertex(...vertices[i + 6]);
    let n = (i + 1) % 5;
    geom.vertex(...vertices[n + 6]);

    geom.vertex(...vertices[i + 1]);
    geom.vertex(...vertices[n + 1]);
    geom.vertex(...vertices[0]);
  }
  for (let i = 0; i < 5; i++) {
    geom.vertex(...vertices[i + 6]);
    geom.vertex(...vertices[i + 1]);
    let n = (i - 1);
    if (n < 0) {
      n = 4;
    }
    geom.vertex(...vertices[n + 1]);

    geom.vertex(...vertices[i + 6]);
    geom.vertex(...vertices[n + 6]);
    geom.vertex(...vertices[11]);
  }
  geom.endShape();
  geom.computeNormals();
}

draw() {
  // ...

  model(geom);
}

Functions that should be available on p5.Geometry created in this way (could actually be a different type, like p5.MutableGeometry):

  • beginShape()
  • endShape()
  • vertex()
  • normal()
  • plane()
  • box()
  • sphere()
  • cylinder()
  • cone()
  • ellipsoid()
  • torus()
  • applyMatrix()
  • resetMatrix()
  • rotate()
  • rotateX()
  • rotateY()
  • rotateZ()
  • scale()
  • shearX()
  • shearY()
  • translate()
  • push()
  • pop()
  • clear()
  • computeNormals()

Each call that creates new faces should cause the geometry id to be regenerated so that the next time it is drawn using model() the WebGL buffers are recreated and cached using the new id.

sflanker avatar Aug 28 '21 21:08 sflanker

After some reverse engineering on the codebase, I found a way to use the pre-existing API (beginShape, vertex, endShape) of the immediate mode by extracting the internal Geometry object. The idea here is to call saveShape instead of endShape.

It's a dirty hack, probably bogus as well, but I'm pasting it here if someone might find it useful in the meanwhile for simple cases. Feel free to patch it further (I only tested it with LINES mode).

import p5 from "p5";

// save shape as Geometry from immediate mode
p5.RendererGL.prototype.saveShape = function() {
	this._processVertices(...arguments);
	this.isBezier = false;
	this.isQuadratic = false;
	this.isCurve = false;
	this.immediateMode._bezierVertex.length = 0;
	this.immediateMode._quadraticVertex.length = 0;
	this.immediateMode._curveVertex.length = 0;

	// patch and return geometry
	let g = this.immediateMode.geometry;

	this._savedShapesCount = this._savedShapesCount+1 || 0;
	g.gid = "saved|" + this._savedShapesCount; // assign gid to cache buffer
	g._makeTriangleEdges = function() { return this; }; // shadow this function to avoid loosing edges when `model(...)` is called

	// assign a new geometry to immediateMode to avoid pointer aliasing
	this.immediateMode.geometry = new p5.Geometry();

	return g;
};

p5.prototype.saveShape = function() {
	if (this._renderer.isP3D) {
		return this._renderer.saveShape(...arguments);
	} else {
		console.warn("Don't use saveShape in 2D mode.");
	}
};

mttbernardini avatar Sep 01 '21 09:09 mttbernardini

Wow! Thanks for the proposal. It is cool to see what people are interested in doing with p5's WebGL mode.

The mission with any additions to the library is to increase access. This is broadly defined but this is also a pretty advanced use-case, it is hard for me to say that it could fall under that umbrella.

That said, I agree that it is unfortunate that there are so many things that p5 does with retained geometry that are currently unavailable to people using the library.

I like @sflanker 's proposal (also that's a fun hack @mttbernardini !) but I think that the complexity would need to be reduced even further to begin making an argument for accessibility. What if createGeometry() took an array and a drawing mode as parameters? I am typing as I think so this suggestion may be silly. Also, I think we would need to spend some time thinking about use cases.

stalgiag avatar Sep 01 '21 22:09 stalgiag

For me personally the use case is to generate a tree procedurally using an L-system (see https://alphaxmas.bubblefish.studio).

My first attempt was to simply map each character of the sentence to a turtle movement using normal p5 primitives (i.e. rotateZ for rolling, line and translate for leaving a trace while moving forward, etc..). This approach performed poorly because turtle interpretation takes some time (especially with longer sentences) and I would be doing this every draw cycle, with an obvious fps drop.

Then I discovered the beginShape/vertex/endShape pattern. It made sense that I could do the turtle interpretation separately and just cache the computed vertices, so every draw cycle I would just iterate over the precomputed vertices and call vertex. This approach however also performed poorly as the number of vertices increased for the reasons discussed in this issue.

That's why I needed to figure out a way to "save" the prepared geometry rather than recreating it every draw cycle (see https://github.com/bubblefishstudio/alphaXmas/blob/main/frontend/src/p5/model.js#L72).

I think I wouldn't be the only one needing to do procedural model generation, but I agree that there should be a simple and consistent API for this case (which I think it might seem advanced but in reality it's a needed step for appropriate performance scaling, immediate mode can't help more than drafting ideas)

mttbernardini avatar Sep 02 '21 07:09 mttbernardini

Great proposal! I hope this can be implemented. I'm looking forward to it.

EmmanuelPil avatar Sep 09 '21 11:09 EmmanuelPil

Take a look at this thread, there should be a few examples in there showing how to make custom geometry.

aferriss avatar Sep 09 '21 17:09 aferriss

Adding on to the existing discussion, a few more ideas:

There are kind of already docs for p5.Geometry, but just lacking concrete examples. When people ask about it on the p5 discord I normally point them to the source code for shapes like sphere. Maybe the lowest impact change would be to just add more content to that page? I have an example making a subdivided cube that I show people sometimes, and it'd be great if the docs explained what to do in the constructor's callback function, namely:

  • Add a p5.Vector for each vertex to this.vertices
  • Optionally add a [u, v] array for each vertex into this.uvs
  • Optionally add a p5.Vector surface normal for each vertex into this.vertexNormals
  • For each face, add an array [i, j, k] to this.faces where each of i, j, and k corresponds to the index of the desired vertex in this.vertices
  • When to use the already documented methods on the reference page (e.g. things like averaging normals)

I'd be willing to write up the above in the doc comments if we want to commit to p5.Geometry being a public thing that people should know how to use.

That said:

The mission with any additions to the library is to increase access. This is broadly defined but this is also a pretty advanced use-case, it is hard for me to say that it could fall under that umbrella.

I'd also argue that the use case for this isn't as advanced as we think. Fractals produce a lot of detail that can be slow to render every frame, but a lot of teaching materials use p5 to teach fractals (e.g. this Nature of Code chapter) and the hardware students learn on is generally not the fastest (e.g. Chromebooks) so people might run into this limitation pretty often. I think making it easier to create p5.Geometry is useful for that.

I think a common question when teaching p5 and CS at the same time is how to reuse shapes. The typical answer to that is to make a function. So before people discover they need p5.Geometry for performance reasons, they've probably got a function encapsulating the shape they want to reuse. Would @sflanker's proposal feel less advanced if we make our API just wrap the function that people already have?

I'm thinking, if people already had a function like this:

function drawIcosahedron() {
  beginShape(TRIANGLES);
  for (let i = 0; i < 5; i++) {
    // etc
  }
  endShape();
}

We could make a helper function that you can wrap around your function to convert the 3D shape it produces into a p5.Geometry. Something like:

const icosahedron = createGeometry(function() {
  beginShape(TRIANGLES);
  for (let i = 0; i < 5; i++) {
    // etc
  }
  endShape();
})

function draw() {
  model(icosahedron)
}

Internally it can push(), run the callback, then run something like @mttbernardini's hack, then pop() and return the new geometry.

This isn't perfect because people might expect it to run their callback every time you use the resulting model when it would actually just run it once upfront. But let me know if you feel like this sort of thing is getting closer to something we might expect beginners to be able to make use of.

davepagurek avatar Jul 13 '22 02:07 davepagurek

I've made a little library that works like the above, in case anyone's interested! https://github.com/davepagurek/p5.buildGeometry

I'm going to try using it for a little while to see if there are limitations to this style of geometry construction. It can always continue to exist just as a library if it doesn't feel right enough to eventually be a core feature. In the mean time, let me know if you try it and have any feedback!

davepagurek avatar Nov 20 '22 21:11 davepagurek