前置き
突然ですが、上の gif 画像のように文字の輪郭を震わせて手描き感のある表現を作りたいです。
この記事は、このような輪郭が震える文字を OpenSiv3D の OutlineGlyph
などの力で再現しようという手探りな試みを記録したものです。
輪郭線のみを描画する
冒頭の gif 画像のように、まずは文字の輪郭のみを描画することを考えます。
公式のサンプル のようにして、各文字毎の輪郭線を取得してみます。
Array<OutlineGlyph> outline_glyphs = font.renderOutlines(text);
Array<LineString> contours; // 文字列の輪郭線
for(OutlineGlyph const & glyph : outline_glyphs){
for(LineString const & ring : glyph.rings){
// ring ... 各文字の各輪郭線
contours.emplace_back(ring);
}
}
次に、震える表現を作るため、輪郭線を構成する各頂点を移動させるような処理を作ります。
今回は単に、ランダムな方向に一定の距離だけ移動させるようにします。
Vec2 scribble_point(Vec2 const& v, double const roughness) {
return v.movedBy(RandomVec2(roughness));
}
scribble_point(v, roughness)
は、頂点 v
をランダムな方向に roughness
の値だけ移動させたときの座標を返す関数です。
この関数を指定した秒数ごとに呼び出すことで、輪郭線を一定間隔で震わせます。
ここまでのコードを実行して描画するとこのようになりました。
これだけでも十分ですが、文字の直線の部分は震えが大雑把であるのに対し、文字の曲線の部分は細かく震えており、震え方が均一でないのが個人的に気になります。
震え方が均一でない理由は頂点の密度にあります。
上の画像は文字 b の頂点を赤で示したものです。
左側の b のように、場所によって頂点の密度が大きく異なると、震え方が均一に見えません。
右側の b のように頂点の密度をなるべく一定に保てば、震えを均一にできそうな気がします。
LineString
には、右側の b のような頂点集合を得る関数 densified()
が用意されているので、これを利用してみることにします。
for(OutlineGlyph const & glyph : outline_glyphs){
for(LineString const & ring : glyph.rings){
contours.emplace_back(ring.densified(10.0, YesNo<CloseRing_tag>::Yes));
}
}
densified(length)
は、隣り合う頂点の距離が length
を超えないように適宜頂点を追加していくようです。
これで実行するとこのようになりました。
思ったよりもぐにゃぐにゃしていますが、震え方が前よりも均一になっています!
輪郭線はこれで上手く震えさせられそうです。
内部も含めて描画する
輪郭だけでなく、内部を塗りつぶすように描画したいです。
PolygonGlyph
を使い、次のようにすることで各文字毎のポリゴンを取得できます。
Array<PolygonGlyph> polygon_glyphs = font.renderPolygons(text);
Array<polygon> polygons; // 文字列のポリゴン
for(PolygonGlyph const & glyph : polygon_glyphs){
for(Polygon const & polygon : glyph.polygons){
// polygon ... 各文字の各多角形要素
polygons.emplace_back(polygon);
}
}
Polygon
が取得できれば、 draw()
を呼ぶことで簡単に描画ができます。
あとは scribble_point()
を使ってポリゴンの各頂点を動かせば、震える見た目が作れそうです。
現状で描画するとこのようになります。
輪郭線のときと同じように、曲線の部分と直線の部分で震え方が均一でないのが目立ちます。
震え方を均一にして内部も含めて描画する
震え方を均一にしたいので、輪郭線のときと同じく頂点を追加していきたいです。
ただし、輪郭線のときと異なり、ポリゴンに頂点を追加するのはそこまで簡単ではありません。 頂点を追加するときに、ポリゴンの面の情報も正しく更新する必要があるためです。
ポリゴンに頂点を追加する操作には 半辺(ハーフエッジ)データ構造 を使いました。
半辺データ構造については長くなるので、別の記事にまとめています。
ポリゴンへの頂点の追加は大変ですが、半辺データ構造によって可能になったこととします。
どのように頂点を追加していくかについてですが、基本的には LineString
の densified()
と同じ発想で、頂点が疎である部分に頂点を追加していくことを考えます。
上の画像は文字 b のポリゴンを示したものです。
通常のポリゴンは左側の b のようになります。
今回の目指すポリゴンが右側の b のようなものになります。すなわち、頂点の密度が低い輪郭上に頂点を増やし、それに伴い適切に面を作成したものです。
ポリゴンへの頂点と面の追加により、右側の b のようなポリゴンを頑張って作っていきます。
面の場合分け
境界辺上に頂点を追加していけば十分なので、処理的には各半辺について境界辺かどうかを判定し、境界辺であれば頂点を追加するような感じになりそうです。
ポリゴンの各面は、境界辺に注目すると以下の 4 パターンに分類できます。
- 境界辺がない
- 境界辺が 1 つある
- 境界辺が 2 つある
- 境界辺が 3 つある
これらについて場合分けして考えます。
境界辺が無い
実線で示した三角形が現在注目している面で、破線で示した辺が隣接する面の辺です。
この面は境界辺が無く、三辺が全て内部辺です。
内部辺に頂点を追加しても無意味なため、このケースでは何もしません。
境界辺が 1 つある
青で示した辺が境界辺です。
このケースではその境界辺上に頂点を追加していきます。
図の赤線が頂点追加により新たに発生する辺になります。
図の例では、頂点が新たに 5 つ追加され、 1 つの面が 6 つの三角形に分割されています。
図のように、境界辺上に配置された頂点と境界辺の対角頂点を結ぶことで三角形が作れるため、面の更新は比較的楽です。
境界辺が 2 つある
2 つの境界辺上に点を追加していきたいところですが、このケースでは面の接続情報の構築が厄介です。
追加した頂点を単純に結ぶだけでは、四角形の面ができてしまったり、面の自己交差が発生してしまい、正しく結ぼうとすると複雑なルールが必要になります。
そのため、このケースでは edge split により面を分割し、境界辺が 1 つであるパターンに帰着させることを考えます。
1 つある内部辺を edge split することによって、図の赤線のような辺が新たに発生します。これによって、境界辺が 2 つあった面は、境界辺が 1 つの面 2 つに分割されることになります。後は境界辺が 1 つのパターンと同じように処理します。
境界辺が 3 つある
境界辺が 3 つあるということは、ポリゴンがその 1 つの面のみからなることを意味しています。中々珍しいケースですが、文字で言うと「▲」などはこれに該当する可能性があります。
この場合でも、各境界上に頂点を追加したとして面の接続情報の構築が難しいです。
そのため、境界辺が 2 つの場合と同じように三角形を分割し境界辺が 1 つの場合へ帰着させます。
具体的には、 edge split の操作を 2 回行い、図の赤線のように辺を引き、境界辺が 1 つである面を 4 つ生成します。後は境界辺が 1 つのパターンで処理できます。
以上により、境界辺が無い面を除く全ての面は境界辺が 1 つであるケースに帰着できます。
これで正しい面の接続情報を持ったポリゴンができました。
得られた頂点リストと面リストは Shape2D
のコンストラクタに渡します。
完成!
これで得たポリゴンを震わせた結果が以下になります。
若干震え方が過剰な気もしますが、大きな偏りなく文字が震えています!
ソースコードを載せておきます。
ソースコード
# include <Siv3D.hpp> // Siv3D v0.6.12
Array<Vec2> scribble_points(Array<Vec2> const& base, double const roughness) {
Array<Vec2> result = base;
for (Vec2& v : result) {
v.moveBy(RandomVec2(roughness));
}
return result;
}
Array<LineString> scribble_linestrings(Array<LineString> const& base, double const roughness) {
Array<LineString> result = base;
for (LineString& linestring : result) {
for (Vec2& v : linestring) {
v.moveBy(RandomVec2(roughness));
}
}
return result;
}
Array<Shape2D> scribble_polygons(Array<Shape2D> const & base, double const roughness) {
Array<Shape2D> result;
for (Shape2D const& shape : base) {
Array<Float2> vertices = shape.vertices();
for (Float2& v : vertices) {
v.moveBy(RandomVec2(roughness));
}
result.emplace_back(Shape2D(std::move(vertices), shape.indices()));
}
return result;
}
Shape2D densified_shape2d(Shape2D const& shape, double const max_distance, Array<Line> & debug_edge, Array<Triangle> & debug_tris) {
using edge_type = std::pair<int32, int32>;
Array<Float2> vertices = shape.vertices();
Array<TriangleIndex> faces = shape.indices();
// ある面のある辺を取得する
auto get_edge = [&](int32 const face_idx, int32 const edge_idx) -> edge_type {
edge_type edge;
switch (edge_idx) {
case 0:
// i0 -> i1 の辺
edge.first = faces[face_idx].i0;
edge.second = faces[face_idx].i1;
break;
case 1:
// i1 -> i2 の辺
edge.first = faces[face_idx].i1;
edge.second = faces[face_idx].i2;
break;
case 2:
// i2 -> i0 の辺
edge.first = faces[face_idx].i2;
edge.second = faces[face_idx].i0;
break;
}
return edge;
};
// 辺の始点と終点をひっくり返す
auto swapped = [](edge_type const& edge) -> edge_type {
edge_type result = edge;
std::swap(result.first, result.second);
return result;
};
// ある面の辺の対角頂点番号を取得する
auto v_opps = [&](int32 const face_idx, edge_type const edge) -> int32 {
if (faces[face_idx].i0 != edge.first && faces[face_idx].i0 != edge.second) {
return faces[face_idx].i0;
}
else if (faces[face_idx].i1 != edge.first && faces[face_idx].i1 != edge.second) {
return faces[face_idx].i1;
}
else {
return faces[face_idx].i2;
}
};
// ハーフエッジにより境界辺を探す
HashTable<edge_type, int32> opposite_edge;
for (int32 i = 0; i < (int32)faces.size(); ++i) {
for (int32 j = 0; j < 3; ++j) {
edge_type edge = get_edge(i, j);
if (opposite_edge.contains(swapped(edge))) {
// 既に反対辺が含まれているならばお互いにその辺番号を登録
opposite_edge[edge] = -(opposite_edge[swapped(edge)] + 1);
opposite_edge[swapped(edge)] = i * 3 + j;
}
else {
// まだその辺の反対辺が無い場合、自分の辺番号を負にして登録しておく
opposite_edge[edge] = -(i * 3 + j + 1);
}
}
}
// 各面を走査して境界辺の数に応じて処理
Array<int32> face_erase_booking; // 処理後に消去する面のインデックス
HashSet<int32> ignore_face;
for (int32 i = 0; i < (int32)faces.size(); ++i) {
// もし無視される面であれば飛ばす
if (ignore_face.contains(i)) {
continue;
}
// 境界辺の数により処理を分岐
Array<edge_type> boundary_edges;
for (int32 j = 0; j < 3; ++j) {
// ある面の境界辺の数=opposite_edgeが負である辺の数
edge_type edge = get_edge(i, j);
if (opposite_edge[edge] < 0) {
boundary_edges.emplace_back(edge);
}
}
switch (boundary_edges.size()) {
case 0:
// 境界辺が 0 なので内部の面、何もしない
break;
case 1:
// 境界辺が 1 なので辺を再分割して頂点と面を追加
{
edge_type const edge = boundary_edges.front();
Float2 const v0 = vertices[edge.first];
Float2 const v1 = vertices[edge.second];
double const distance = v0.distanceFrom(v1);
Vec2 const e = (v1 - v0).normalized();
int32 v_idx0 = edge.first;
int32 v_idx1 = edge.second;
int32 v_idx2 = v_opps(i, edge);
int32 loop_num = (int32)(distance / max_distance);
if (loop_num > 0) {
for (int32 j = 0; j < loop_num; ++j) {
// 新しい頂点を追加する
Float2 new_v = v0 + e * max_distance * (j+1);
vertices.emplace_back(new_v);
// 新しい面を追加する
v_idx1 = (int32)vertices.size() - 1;
faces.emplace_back(v_idx0, v_idx1, v_idx2);
// 追加した面は境界辺が 0 であれば自動的に何もしないので、 opposite_edge の変更は無くてもよい
v_idx0 = v_idx1;
}
// 新しい面の追加
faces.emplace_back(v_idx0, edge.second, v_idx2);
// 細分割をした面は後で消す
face_erase_booking.emplace_back(i);
}
}
break;
case 2:
{
// 境界辺が 2 なので面を特定の形に分割
// 1 つある内部辺の中点に新しい頂点を設置する
edge_type edge;
for (int32 j = 0; j < 3; ++j) {
// 内部辺は反対辺の正の値を持っている
if (opposite_edge[get_edge(i, j)] >= 0) {
edge = get_edge(i, j);
}
}
debug_edge.emplace_back(vertices[edge.first], vertices[edge.second]);
int32 opposite_face_idx = opposite_edge[edge] / 3;
debug_tris.emplace_back(vertices[faces[i].i0], vertices[faces[i].i1], vertices[faces[i].i2]);
debug_tris.emplace_back(vertices[faces[opposite_face_idx].i0], vertices[faces[opposite_face_idx].i1], vertices[faces[opposite_face_idx].i2]);
edge_type opps_edge = get_edge(opposite_face_idx, opposite_edge[edge] % 3);
Float2 new_v = (vertices[edge.first] + vertices[edge.second]) / 2;
vertices.emplace_back(new_v);
// これにともなって新しい面を追加する
int32 v_new_idx = vertices.size() - 1;
int32 v_idx0 = edge.first;
int32 v_idx1 = edge.second;
int32 v_idx2 = v_opps(i, edge);
int32 v_idx3 = v_opps(opposite_face_idx, opps_edge);
// 境界側の面
faces.emplace_back(v_idx0, v_new_idx, v_idx2);
faces.emplace_back(v_new_idx, v_idx1, v_idx2);
// 反対辺が所属する面
faces.emplace_back(v_idx1, v_new_idx, v_idx3);
faces.emplace_back(v_new_idx, v_idx0, v_idx3);
// 面の追加に伴い opposite_edge を適切に修正する
// 追加面 0 のハーフエッジ
int32 faces_num = (int32)faces.size();
opposite_edge[edge_type(v_idx0, v_new_idx)] = (faces_num - 1) * 3 + 0;
opposite_edge[edge_type(v_new_idx, v_idx2)] = (faces_num - 3) * 3 + 2;
opposite_edge[edge_type(v_idx2, v_idx0)] = -((faces_num - 4) * 3 + 2 + 1);
// 追加面 1 のハーフエッジ
opposite_edge[edge_type(v_new_idx, v_idx1)] = (faces_num - 2) * 3 + 0;
opposite_edge[edge_type(v_idx1, v_idx2)] = -((faces_num - 3) * 3 + 1 + 1);
opposite_edge[edge_type(v_idx2, v_new_idx)] = (faces_num - 4) * 3 + 1;
// 反対辺が所属する追加面 2, 3 に関しては旧ハーフエッジへの opposite を更新する
// 追加面 2 のハーフエッジ
opposite_edge[edge_type(v_idx1, v_new_idx)] = (faces_num - 3) * 3 + 0;
opposite_edge[edge_type(v_new_idx, v_idx3)] = (faces_num - 1) * 3 + 2;
if (opposite_edge[edge_type(v_idx3, v_idx1)] < 0) {
// 反対辺が無い場合は境界の値だけ更新する
opposite_edge[edge_type(v_idx3, v_idx1)] = -((faces_num - 2) * 3 + 2 + 1);
}
else {
// 反対辺がある場合は反対辺側を更新する(3->1 は既に正しい反対辺を持っている)
opposite_edge[edge_type(v_idx1, v_idx3)] = (faces_num - 2) * 3 + 2;
}
// 追加面 3 のハーフエッジ
opposite_edge[edge_type(v_new_idx, v_idx0)] = (faces_num - 4) * 3 + 0;
if (opposite_edge[edge_type(v_idx0, v_idx3)] < 0) {
// 反対辺が無い場合は境界の値だけ更新する
opposite_edge[edge_type(v_idx0, v_idx3)] = -((faces_num - 1) * 3 + 1 + 1);
}
else {
// 反対辺がある場合は反対辺側を更新する(0->3 は既に正しい反対辺を持っている)
opposite_edge[edge_type(v_idx3, v_idx0)] = (faces_num - 1) * 3 + 1;
}
opposite_edge[edge_type(v_idx3, v_new_idx)] = (faces_num - 2) * 3 + 1;
// 現在の面は後で消す
face_erase_booking.emplace_back(i);
face_erase_booking.emplace_back(opposite_face_idx);
// 反対辺が所属する面を以降走査しないようにする
ignore_face.emplace(opposite_face_idx);
}
break;
case 3:
{
// 境界辺が 3 なので面を特定の形に分割
// ある辺の中点に新たに頂点を配置して対角頂点と辺を結び、新しくできた辺について更に頂点を加える
// 頂点位置を計算
int32 v_idx0 = faces[i].i0;
int32 v_idx1 = faces[i].i1;
int32 v_idx2 = faces[i].i2;
Float2 new_v0 = (vertices[v_idx0] + vertices[v_idx1]) / 2;
vertices.emplace_back(new_v0);
Float2 new_v1 = (vertices[v_idx2] + new_v0) / 2;
vertices.emplace_back(new_v1);
int32 v_new_idx0 = (int32)vertices.size() - 2;
int32 v_new_idx1 = (int32)vertices.size() - 1;
// 面を追加する
faces.emplace_back(v_idx0, v_new_idx0, v_new_idx1);
faces.emplace_back(v_new_idx0, v_idx1, v_new_idx1);
faces.emplace_back(v_idx1, v_idx2, v_new_idx1);
faces.emplace_back(v_idx2, v_idx0, v_new_idx1);
// 現在の面は後で消す
face_erase_booking.emplace_back(i);
// 1 つの境界辺が分割されているため、それらの opposite_edge が負(境界を意味する)になるように更新する
opposite_edge[edge_type(v_idx0, v_new_idx0)] = -1;
opposite_edge[edge_type(v_new_idx0, v_idx1)] = -1;
}
break;
}
}
std::sort(face_erase_booking.begin(), face_erase_booking.end(), std::greater<int32>());
// 消去予定だった面を全て消す
for (int32 i = 0; i < (int32)face_erase_booking.size(); ++i) {
faces.erase(faces.begin() + face_erase_booking[i]);
}
// 加工した vertices と faces で Shape2D を作って返す
return Shape2D(std::move(vertices), std::move(faces));
}
void Main()
{
// Window::Resize(1000, 800);
Camera2D camera(Scene::Center(), 1.0);
// Scene::SetBackground(ColorF{ 0.6, 0.8, 0.7 });
Scene::SetBackground(Palette::White);
// フォントとテキストの準備
Font const font{ FontMethod::MSDF, 100, Typeface::Bold };
// Font const font{ 400, Typeface::Bold };
Font const font_small{ FontMethod::MSDF, 30, Typeface::Bold };
String text = U"Hello, World!▲";
Vec2 base_pos(20, 120);
double y_margin = 140;
Vec2 draw_pos;
// 更新頻度の設定
Stopwatch stopwatch(YesNo<StartImmediately_tag>::Yes);
double frequency = 0.1;
// 粗さ
double roughness = 1;
// 線分の長さ
double max_length = 5;
// ワイヤフレームの太さ
double thickness = 0.1;
// ワイヤフレームオンオフ
bool is_wireframe = false;
// 輪郭のみの場合 -----
Array<OutlineGlyph> outline_glyphs = font.renderOutlines(text);
Array<LineString> linestrings_base;
Array<LineString> linestrings_scribble;
// OutlineGlyph から LineString を取得する
draw_pos = base_pos;
for (OutlineGlyph const& glyph : outline_glyphs) {
Vec2 draw_offset = draw_pos + glyph.getOffset();
for (LineString const & ring : glyph.rings) {
linestrings_base.emplace_back(ring.densified(max_length, YesNo<CloseRing_tag>::Yes).movedBy(draw_offset));
// linestrings_base.emplace_back(ring.movedBy(draw_offset));
}
draw_pos.x += glyph.xAdvance;
}
linestrings_scribble = scribble_linestrings(linestrings_base, roughness);
// 内部含めて描画する場合 -----
Array<PolygonGlyph> polygon_glyphs = font.renderPolygons(text);
Array<Polygon> polygons_base;
Array<Shape2D> shapes_base;
Array<Shape2D> shapes_scribble;
// PolygonGlyph から Polygon を取得する
draw_pos = base_pos.movedBy(0, y_margin);
for (PolygonGlyph const& glyph : polygon_glyphs) {
Vec2 draw_offset = draw_pos + glyph.getOffset();
for (Polygon const& polygon : glyph.polygons) {
polygons_base.emplace_back(polygon.movedBy(draw_offset));
}
draw_pos.x += glyph.xAdvance;
}
// Polygon の頂点と面情報から Shape2D を作る
shapes_base.resize(polygons_base.size());
for (int32 i = 0; i < (int32)shapes_base.size(); ++i) {
shapes_base[i] = Shape2D(polygons_base[i].vertices(), polygons_base[i].indices());
}
shapes_scribble = scribble_polygons(shapes_base, roughness);
// Shape2D の densified -----
Array<Shape2D> densified_shapes_base(shapes_base.size());
Array<Shape2D> densified_shapes_scribble(shapes_base.size());
Array<Line> debug_lines;
Array<Triangle> debug_tris;
// Array<Shape2D> densified_shapes_scribble(shapes_base.size());
draw_pos = base_pos.movedBy(0, y_margin * 2);
for (int32 i = 0; i < (int32)shapes_base.size(); ++i) {
Array<Float2> vertices = shapes_base[i].vertices();
for (auto& v : vertices) {
v.moveBy(0, y_margin);
}
densified_shapes_base[i] = Shape2D(vertices, shapes_base[i].indices());
}
for (int32 i = 0; i < (int32)densified_shapes_base.size(); ++i) {
densified_shapes_base[i] = densified_shape2d(densified_shapes_base[i], max_length, debug_lines, debug_tris);
}
densified_shapes_scribble = scribble_polygons(densified_shapes_base, roughness);
while (System::Update())
{
camera.update();
if (KeySpace.down()) {
camera.jumpTo(Scene::Center(), 1.0);
}
{
// 描画
auto const transformer = camera.createTransformer();
SimpleGUI::Slider(U"Roughness:{:.2f}"_fmt(roughness), roughness, 0.01, 5.0, Vec2(30, 60), 160, 150);
SimpleGUI::Slider(U"frequency:{:.2f}"_fmt(frequency), frequency, 0.05, 0.3, Vec2(400, 60), 160, 150);
SimpleGUI::Slider(U"thickness:{:.2f}"_fmt(thickness), thickness, 0.05, 0.5, Vec2(30, 100), 160, 150);
SimpleGUI::CheckBox(is_wireframe, U"wireframe", Vec2(400, 100), 200);
if (stopwatch.sF() > frequency) {
linestrings_scribble = scribble_linestrings(linestrings_base, roughness);
shapes_scribble = scribble_polygons(shapes_base, roughness);
densified_shapes_scribble = scribble_polygons(densified_shapes_base, roughness);
stopwatch.restart();
}
for (auto const& linestring : linestrings_scribble) {
// LineString(linestring).drawClosed(Palette::Black);
linestring.drawClosed(Palette::Black);
}
for (auto const& shape : shapes_scribble) {
if (is_wireframe) {
shape.drawWireframe(thickness, Palette::Black);
}
else {
shape.draw(Palette::Black);
}
}
for (auto const& shape : densified_shapes_scribble) {
if (is_wireframe) {
shape.drawWireframe(thickness, Palette::Black);
}
else {
shape.draw(Palette::Black);
}
}
}
camera.draw(Palette::Orange);
}
}
問題点
この手法は退化や自己交差が発生しないよう有効なポリゴンを生成していますが、結局最後の頂点をランダムに移動させるステップで面の裏返りや自己交差が大量に発生してしまい、それまでの処理が台無しになっています。
描画するとそれなりにうまくいっているように見えますが、実際には正しくないポリゴンを描画していることになっているわけです。
これについて解決策を考えます。
- 頂点の動かし方を変える
- 隣接する頂点への距離によって頂点を動かす距離を変えれば自己交差は減らせそうです。ただし頂点の疎な部分と密な部分で震え方が変わりそう。
- cage based deformation を使って頂点を動かすと自己交差が減らせるかも。
- 後処理で自己交差や裏帰りに対処
- 自己交差を後処理的に解消するのは難しそうです。
- 別のアプローチにする
- 今回は直線部分の頂点を増やすアプローチをとりましたが、逆に曲線部分の頂点を減らすアプローチも取れそうです。
- 面リストを使わずにシェーダのステンシルバッファを使って多角形を描画する。
現段階ではこれらはまだ試せていないです。
また、更に別の致命的な問題点......現在の処理では明らかに見た目がおかしくなってしまうケースが存在します。
震えによって「輪」の字の一部が繋がったり途切れたりしています!
フォントにもよると思いますが、漢字だとこのようなことになりやすそうな気がします。
PolygonGlyph
から得たポリゴンをそのまま使うのではなく加工するか、あるいはポリゴンを使わずにやる方が良いのかもしれません。
雑感
もう少し楽な方法がありそうなものですが良い案が思い浮かばず、結果も想定していたものとは少し違う震え方になってしまいました。
なんとなく、頂点を増やすよりも、頂点を減らしたり動かす頂点を少なくした方が理想としていた震え方に近くなる気がします。
課題はまだ多いですが、とりあえずそれっぽく文字を震わせることができたのでよかったです。