Skip to content

Commit

Permalink
Ratio interval half way 7. (Only a figure is left to draw)
Browse files Browse the repository at this point in the history
  • Loading branch information
ShenCiao committed Sep 5, 2024
1 parent 1dadc5e commit 71c383a
Show file tree
Hide file tree
Showing 9 changed files with 517 additions and 9 deletions.
28 changes: 21 additions & 7 deletions docs/Proportional-Interval-Stamp/Proportional-Interval-Stamp.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,10 @@ Proportional interval has another benefit: Strokes have more consistent appearan
Watch the start and end points of the fixed interval stroke.
Because the stroke radius drops to zero at these points, these dots are too sparser to keep a continuous feeling.
In contrast, the proportional interval is consistent all along the stroke.
Actually, it's a [fractal](https://en.wikipedia.org/wiki/Fractal) like mandelbrot set.
Actually, it's a [fractal](https://en.wikipedia.org/wiki/Fractal) when a radius comes to zero.

![zoom](./zoom-in.gif)
<figcaption> Zoom in until getting numeric error</figcaption>

Krita supports the proportional interval.
We can find the "Auto" toggle button under the "Spacing" setting, as shown in the figure below.
Expand All @@ -154,16 +157,17 @@ float currRadius = firstStampRadius;
while (currPos != endPos){
drawFootprint(currPos, currRadius);
// highlight-next-line
float interval = currRadius * interval_ratio;
float interval = currRadius * intervalRatio;
vec2 nextPos = addAlongThePolyline(currPos, interval);
vec2 nextRadius = radius(nextPos);
currPos = nextPos; currRadius = nextRadius;
}
```

The `nextPos` depends on `currPos`, so the process cannot be GPU-accelerated.
To overcome this, we need to eliminate this dependency while maintaining the appearance of the stroke.
It's a significant challenge and will require some calculus to solve it.
Our goal is to eliminate this dependency while maintaining the appearance of the stroke.
It's a challenge, but we will solve it with very small code changes compared to the Stamp section.
However, understanding the theory behind is crucial, and it requires some knowledge of calculus.

## Theory
Given a polyline, pick one of its edges and divide it into infinitely small segments of length $\Delta x$.
Expand Down Expand Up @@ -201,7 +205,7 @@ $$
As $x = L$, we know the total stamp number on the edge, remind that $\cos\theta L = r_0-r_1$:

$$
\tag{3} n(L) = \frac{1}{\eta \cos\theta}\ln \frac{r_0}{r_1}
\tag{3} n(L) = \frac{L}{\eta (r_0-r_1)}\ln \frac{r_0}{r_1}
$$

We will soon use the formula (1)(2)(3) in our code.
Expand Down Expand Up @@ -235,8 +239,18 @@ Additionally, to determine $n_0$ and $n_1$, we can compute the prefix sum of the
Put them into vertex data and pass them into fragment shader, exactly same as the value `length` in the Stamp section.

## Implementation
You can verify the correctness of code by changing the variable `intervalRatio`:

import Stroke from "./ProportionalStampStroke";

<Stroke/>

## Corner case
**Zero division:**
It's common for strokes to have zero radii at their starting or ending vertices.
When either $r_0$ or $r_1$ is zero, the value in formula (3) can approach infinity.
This will bring numeric errors, which we surely want to avoid.
A simple solution is to add a small number to the radii, as shown in the code above.
For a more rigorous approach, you can implement checks to prevent $r_0/r_1$ in the logarithm from becoming zero or excessively large.

## Proof of properties
In the Stamp Pattern subsection, we claim that "stamp interval is always proportional to radius of stroke" and
Expand Down Expand Up @@ -327,7 +341,7 @@ we can derive an identical quadratic equation with variable $\frac{r(x_1)}{r_q}$
Two roots of this quadratic equation are $\frac{r(x_1)}{r_q}$ and $\frac{r(x_2)}{r_q}$,
their ratio is $\frac{r(x_1)}{r(x_2)}$.

Computing the roots of quadratic equation is why we have a big block of sqrt in the result.
Computing the roots of quadratic equation is why we have a big block of sqrt with $\pm$ sign in the result.
If you are interested in more details about the solving process, check the drop-down tab below.

<details>
Expand Down
15 changes: 15 additions & 0 deletions docs/Proportional-Interval-Stamp/ProportionalStampStroke.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import Stroke from "./Stroke";
import geomCode from "./proportionalIntervalGeometry";
import vsCode from "./stampProportional.vert";
import fsCode from "./stampProportional.frag";

export default function ({ showEditor = [true, true, true] }) {
return (
<Stroke
geometry={geomCode}
vertexShader={vsCode}
fragmentShader={fsCode}
showEditor={showEditor}
/>
);
}
300 changes: 300 additions & 0 deletions docs/Proportional-Interval-Stamp/Stroke.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,300 @@
import { useCallback, useEffect, useRef } from "react";
import * as THREE from "three";
// @ts-ignore
import { MapControls } from "three/examples/jsm/controls/MapControls.js";
import Tabs from "@theme/Tabs";
import TabItem from "@theme/TabItem";
import Editor from "@monaco-editor/react";
import { editor } from "monaco-editor";
import { GlslEditor } from "@site/src/components//GlslEditor";
import ExecutionEnvironment from "@docusaurus/ExecutionEnvironment";
import docusaurusConfig from "@site/docusaurus.config";

export default function ({
geometry,
vertexShader,
fragmentShader,
showEditor = null,
uniforms = null,
}) {
const canvasContainerRef = useRef<HTMLDivElement>();
const renderSceneFnRef = useRef<Function>();
const meshRef = useRef<THREE.InstancedMesh>();
const rendererRef = useRef<THREE.WebGLRenderer>();

useEffect(() => {
// Being in doubt when setting parameters? Use golden ratio!
const gr = (1 + Math.sqrt(5)) / 2;
const canvasWidth = canvasContainerRef.current.clientWidth;
const canvasHeight = canvasWidth * (0.5 / gr);

const worldWidth = 5 * gr;
const worldHeight = worldWidth * (0.5 / gr);
const camera = new THREE.OrthographicCamera(
worldWidth / -2,
worldWidth / 2,
worldHeight / 2,
worldHeight / -2,
-1000,
1000,
);
camera.position.z = 5;

const renderer = new THREE.WebGLRenderer({
preserveDrawingBuffer: true,
powerPreference: "high-performance",
antialias: true,
alpha: true,
premultipliedAlpha: false,
});
renderer.setClearColor(new THREE.Color(1.0, 1.0, 1.0), 0.0);
renderer.setSize(canvasWidth, canvasHeight);
rendererRef.current = renderer;

function resizeRenderer() {
const canvasWidth = canvasContainerRef.current.clientWidth;
const canvasHeight = (canvasWidth * 0.5) / gr;
renderer.setSize(canvasWidth, canvasHeight);
}

window.addEventListener("resize", resizeRenderer);
canvasContainerRef.current.appendChild(renderer.domElement);

let texture = new THREE.Texture();
if (ExecutionEnvironment.canUseDOM) {
texture = new THREE.TextureLoader().load(
`/${docusaurusConfig.projectName}/img/dot-transparent.png`,
(texture) => {
window.dispatchEvent(new CustomEvent("TextureLoaded"));
},
undefined,
undefined,
);
}

let textureUniforms = {
footprint: {value: texture},
intervalRatio: {value: 1.0}
};

const scene = new THREE.Scene();
const controls = new MapControls(camera, renderer.domElement);
controls.enableRotate = false;
controls.enableDamping = false;
controls.screenSpacePanning = true;
controls.addEventListener("change", () => {
renderer.render(scene, camera);
});
renderSceneFnRef.current = () => renderer.render(scene, camera);
// @ts-ignore
window.addEventListener("TextureLoaded", renderSceneFnRef.current);

let trapezoidGeometry = new THREE.BufferGeometry();
if (typeof geometry == "string") {
// automatically create buffers from position and radius
const indices = [0, 1, 2, 2, 3, 0];
trapezoidGeometry.setIndex(indices);
const getGeom = new Function(geometry);
const [position, radius, index, intervalRatio] = getGeom();
textureUniforms.intervalRatio.value = intervalRatio;
updateGeometry(trapezoidGeometry, position, radius, index);

} else {
console.error("Unrecognized geometry input: " + typeof geometry);
return;
}

if(uniforms){
textureUniforms = uniforms;
}

const material = new THREE.RawShaderMaterial({
vertexShader: vertexShader,
fragmentShader: fragmentShader,
side: THREE.DoubleSide,
transparent: true,
glslVersion: THREE.GLSL3,
uniforms: textureUniforms,
});

meshRef.current = new THREE.InstancedMesh(
trapezoidGeometry,
material,
trapezoidGeometry.getAttribute("radius0").count - 1,
);
meshRef.current.frustumCulled = false;
scene.add(meshRef.current);
renderSceneFnRef.current();

return () => {
renderer.dispose();
window.removeEventListener("resize", resizeRenderer);
// @ts-ignore
window.removeEventListener("TextureLoaded", renderSceneFnRef.current);
};
}, []);

function updateGeometry(
geometry: THREE.BufferGeometry,
position: number[],
radius: number[],
index: number[],
) {
const position0 = [...position];
const position1 = [...position.slice(2)];
const radius0 = [...radius];
const radius1 = [...radius.slice(1)];
const index0 = [...index];
const index1 = [...index.slice(1)];

geometry.setAttribute(
"position0",
new THREE.InstancedBufferAttribute(new Float32Array(position0), 2),
);
geometry.setAttribute(
"radius0",
new THREE.InstancedBufferAttribute(new Float32Array(radius0), 1),
);
geometry.setAttribute(
"position1",
new THREE.InstancedBufferAttribute(new Float32Array(position1), 2),
);
geometry.setAttribute(
"radius1",
new THREE.InstancedBufferAttribute(new Float32Array(radius1), 1),
);
geometry.setAttribute(
"index0",
new THREE.InstancedBufferAttribute(new Float32Array(index0), 1),
);
geometry.setAttribute(
"index1",
new THREE.InstancedBufferAttribute(new Float32Array(index1), 1),
);
}

function updateMaterial(vert: string, frag: string, intervalRatio: number = NaN) {
const material = meshRef.current.material as THREE.RawShaderMaterial;
if (vert) {
material.vertexShader = vert;
}
if (frag) {
material.fragmentShader = frag;
}
if (!isNaN(intervalRatio)){
material.uniforms.intervalRatio.value = intervalRatio;
}
material.needsUpdate = true;
}

const onGeometryEditorChange = useCallback(
(value: string | undefined, ev: editor.IModelContentChangedEvent) => {
let position: number[] = [];
let radius: number[] = [];
let index: number[] = [];
let intervalRatio:number = 1.0;
try {
const getGeom = new Function(value);
[position, radius, index, intervalRatio] = getGeom();
} catch (e) {
console.log(e.toString());
return;
}

// Validation
function isArrayOfNumbers(value) {
if (Array.isArray(value)) {
for (let i = 0; i < value.length; i++) {
if (typeof value[i] !== "number") {
return false; // If one of the elements of the array is not a number, return false
}
}
return true;
}
return false;
}
if (!isArrayOfNumbers(position) || !isArrayOfNumbers(radius)) {
console.error("return value is not correct");
return;
}
if (position.length != radius.length * 2) {
console.error("return value is not correct");
return;
}

updateGeometry(meshRef.current.geometry, position, radius, index);
updateMaterial("", "", intervalRatio);
meshRef.current.count = radius.length - 1;
renderSceneFnRef.current();
},
[],
);

const editorHeight = "60vh";
let showGeometryEditor = true,
showVertexEditor = true,
showFragmentEditor = true;
if (Array.isArray(showEditor)) {
[showGeometryEditor, showVertexEditor, showFragmentEditor] = showEditor;
showEditor = showGeometryEditor || showVertexEditor || showFragmentEditor;
}

if (geometry instanceof THREE.BufferGeometry) {
showGeometryEditor = false;
}

return (
<>
{showEditor && (
<div>
<Tabs defaultValue="">
{showGeometryEditor && (
<TabItem value="geometry.js">
<Editor
height={editorHeight}
defaultLanguage="javascript"
defaultValue={geometry}
onChange={onGeometryEditorChange}
/>
</TabItem>
)}
{showVertexEditor && (
<TabItem value="vertex.glsl">
<GlslEditor
height={editorHeight}
defaultValue={vertexShader}
onChange={(value) => {
updateMaterial(value, "");
renderSceneFnRef.current();
}}
/>
</TabItem>
)}
{showFragmentEditor && (
<TabItem value="fragment.glsl">
<GlslEditor
height={editorHeight}
defaultValue={fragmentShader}
onChange={(value) => {
updateMaterial("", value);
renderSceneFnRef.current();
}}
/>
</TabItem>
)}
</Tabs>
</div>
)}
<div
ref={canvasContainerRef}
style={{ width: "100%" }}
onMouseDown={(e) => {
e.preventDefault();
if (e.button == 2) {
console.log(rendererRef.current.domElement.toDataURL());
}
}}
/>
</>
);
}
Loading

0 comments on commit 71c383a

Please sign in to comment.